Add interactive form fields to Typst-generated PDFs.
Typst template (contract.typ):
#let capture_field(field_name: "", field_type: "text", content) = {
box({
context {
let pos = here().position()
let size = measure(content)
metadata((
fieldName: field_name, fieldType: field_type,
dimensions: (width: size.width, height: size.height),
pos: (page: pos.page, x: pos.x, y: pos.y),
))
}
content
})
}
= Service Agreement
This Agreement is entered into on
#capture_field(field_name: "date", field_type: "text")[
#box(width: 80pt, height: 14pt, stroke: (bottom: 0.5pt))
] by:
*Provider:* #capture_field(field_name: "provider", field_type: "text")[
#box(width: 200pt, height: 14pt, stroke: (bottom: 0.5pt))
]
*Client:* #capture_field(field_name: "client", field_type: "text")[
#box(width: 200pt, height: 14pt, stroke: (bottom: 0.5pt))
]
#capture_field(field_name: "agree", field_type: "checkbox")[
#box(width: 10pt, height: 10pt, stroke: 0.5pt)
] I agree to the termsPython:
from typst_fillable import make_fillable
pdf = make_fillable(template="contract.typ", context={})
with open("fillable.pdf", "wb") as f:
f.write(pdf)Open fillable.pdf in any PDF reader and start typing!
typst-fillable is a Python library that transforms static Typst PDFs into interactive fillable forms. It extracts field position metadata embedded in Typst templates and overlays interactive AcroForm fields using ReportLab.
Key features:
- Create fillable PDFs from Typst templates
- Support for text fields, textareas, checkboxes, and radio buttons
- Customizable field styling
- Works with multi-page documents
- Pre-fill forms with data or generate blank forms
pip install typst-fillableRequirements:
- Python 3.10+
- Typst CLI installed and available in PATH
// form.typ
#import "capture_field.typ": capture_field
#let ctx = json("context.json")
Name: #capture_field(field_name: "name", field_type: "text")[
#box(width: 200pt, height: 14pt, stroke: 0.5pt, fill: rgb("#f7f9fb"))
]
Email: #capture_field(field_name: "email", field_type: "text")[
#box(width: 200pt, height: 14pt, stroke: 0.5pt, fill: rgb("#f7f9fb"))
]from typst_fillable import make_fillable
# Generate blank fillable form
pdf = make_fillable(
template="form.typ",
context={},
root="./templates"
)
with open("fillable_form.pdf", "wb") as f:
f.write(pdf)-
Template Design: Use
capture_field()in your Typst template to mark where interactive fields should appear. The function emits metadata about field position and properties. -
Metadata Extraction: When generating a PDF,
typst-fillablequeries the template usingtypst.query()to extract all field metadata. -
Overlay Creation: ReportLab creates a transparent PDF overlay with interactive AcroForm fields at the exact positions specified in the metadata.
-
Merge: The base Typst PDF and the form overlay are merged using PyPDF to create the final fillable document.
The main entry point for generating fillable PDFs.
def make_fillable(
template: str | Path,
context: dict | None = None,
root: str | Path | None = None,
pdf_bytes: bytes | None = None,
style: FieldStyle | None = None,
) -> bytes:Parameters:
template: Path to the Typst template filecontext: Optional dict to pass ascontext.jsonto the templateroot: Root directory for Typst compilationpdf_bytes: Pre-compiled PDF bytes (skips compilation if provided)style: Custom styling for form fields
Returns: Fillable PDF as bytes
Extract field positions from a Typst template.
def extract_field_metadata(
template_path: str | Path,
root: str | Path | None = None,
) -> list[FieldMetadata]:Create a PDF overlay with interactive form fields.
def create_form_overlay(
fields: list[FieldMetadata],
page_count: int,
page_size: tuple[float, float] = (612.0, 792.0),
style: FieldStyle | None = None,
) -> BytesIO:Merge a base PDF with a form field overlay.
def merge_with_overlay(
base_pdf: bytes,
form_overlay: BytesIO,
) -> bytes:Customize form field appearance.
from typst_fillable import FieldStyle
style = FieldStyle(
fill_color="#ffffff", # Field background color
text_color="#000000", # Text color
font_size=8, # Font size in points
border_width=0, # Border width (0 for none)
)#let capture_field(
field_name: "", // Unique field identifier (required)
field_type: "text", // "text", "textarea", "checkbox", or "radio"
dimensions: (:), // Custom dimensions (optional)
group_name: none, // Radio button group name
fill_cell: false, // Expand to fill table cell
position_offset: (x: 0, y: 0), // Fine-tune position
min_width: none, // Minimum width
min_height: none, // Minimum height
prefix: "", // Text before field (e.g., "$")
suffix: "", // Text after field (e.g., "%")
content // Visual content to display
) = { ... }#capture_field(field_name: "company", field_type: "text")[
#box(width: 200pt, height: 14pt, stroke: 0.5pt + gray, fill: rgb("#f7f9fb"))
]#capture_field(
field_name: "comments",
field_type: "textarea",
fill_cell: true,
min_height: 50pt,
)[
#box(width: 100%, height: 50pt, stroke: 0.5pt + gray, fill: rgb("#f7f9fb"))
]#capture_field(field_name: "agree", field_type: "checkbox")[
#box(width: 12pt, height: 12pt, stroke: 0.5pt + gray, fill: rgb("#f7f9fb"))
]// Same group_name links radio buttons together
#capture_field(field_name: "yes", field_type: "radio", group_name: "answer")[
#box(width: 10pt, height: 10pt, stroke: 0.5pt + gray, radius: 50%)
] Yes
#capture_field(field_name: "no", field_type: "radio", group_name: "answer")[
#box(width: 10pt, height: 10pt, stroke: 0.5pt + gray, radius: 50%)
] No#capture_field(
field_name: "price",
field_type: "text",
prefix: "$",
suffix: ".00",
)[
#box(width: 80pt, height: 14pt, stroke: 0.5pt + gray, fill: rgb("#f7f9fb"))
]For fields inside table cells that should expand to fill the cell:
#table(
columns: (1fr, 1fr),
[Label],
capture_field(
field_name: "value",
field_type: "text",
fill_cell: true,
position_offset: (x: -5, y: 5),
)[
#text[#ctx.at("value", default: "")]
],
)See the examples/ directory for complete working examples:
contact_form/- Professional contact form with sections, radio buttons, and checkboxessurvey/- Customer satisfaction survey with rating scales (1-5) and multiple choicecontract/- Service agreement with signature boxes and legal checkboxesinvoice/- Invoice with line items table, currency fields, and totals
Each example can be run with:
cd examples/<name>
python generate.pyThis project uses uv for fast and reliable Python package management. If you don't have uv installed yet:
# Install uv (macOS/Linux)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or with pip
pip install uv# Clone the repository
git clone https://github.com/carpe-diem/typst-fillable.git
cd typst-fillable
# Create a virtual environment and install dependencies with uv
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install the package in editable mode with dev dependencies
uv pip install -e ".[dev]"If you prefer to use pip:
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install development dependencies
pip install -e ".[dev]"# Run tests
pytest
# Run tests with coverage report
pytest --cov=src/typst_fillable --cov-report=term-missing
# Run linter
ruff check .
# Auto-fix linting issues
ruff check . --fix
# Format code
ruff format .
# Type check
mypy src/typst-fillable/
├── src/typst_fillable/ # Main package source code
├── tests/ # Test suite
├── examples/ # Example forms and usage
├── pyproject.toml # Project configuration
└── README.md # This file
Contributions are welcome! Here's how you can help:
If you find a bug, please open an issue with:
- A clear description of the problem
- Steps to reproduce the issue
- Expected vs actual behavior
- Your Python and Typst versions
Feature requests are welcome! Please open an issue describing:
- The use case for the feature
- How it would work
- Any alternatives you've considered
- Fork the repository
- Create a new branch (
git checkout -b feature/amazing-feature) - Set up your development environment (see Development section above)
- Make your changes
- Run tests and checks to ensure everything passes:
pytest ruff check . mypy src/ - Commit your changes (
git commit -m 'Add amazing feature') - Push to your branch (
git push origin feature/amazing-feature) - Open a Pull Request
- We use Ruff for linting and formatting
- We use mypy for type checking
- Follow PEP 8 guidelines
- Add type hints to all functions
- Write docstrings for public APIs
- Keep line length to 100 characters
- Write tests for new features and bug fixes
- Ensure test coverage remains high
- Use descriptive test names
- Add integration tests for end-to-end scenarios
MIT License - see LICENSE for details.
Alberto Paparelli (@carpe-diem)
If you find this project useful, please consider giving it a star!
