Last updated: 2026-10-05
The normative reference for the document export system: the sixteen templates, the single PDF engine, and the cross-cutting rules (page size, ATS mode, links, fonts, validation). This is a contract — behavior described here is locked by tests; changing it means changing the tests too.
Source of truth in code:
| Concern | Where |
|---|---|
| Template registry (styling data) | apps/desktop/src-tauri/src/export/templates/ |
| Template IDs + serde fallback | apps/desktop/src-tauri/src/export/types.rs (TemplateId) |
| Canonical document model | apps/desktop/src-tauri/src/model/ |
| PDF engine (Typst adapter) | apps/desktop/src-tauri/src/export/typst_engine/ |
| DOCX backend (flow) | apps/desktop/src-tauri/src/export/docx/, export/model_docx.rs |
| Section placement / two-col rules | apps/desktop/src-tauri/src/theme/mod.rs |
| Locale profiles (page size, …) | apps/desktop/src-tauri/src/locale/mod.rs |
| Cover-letter market conventions | apps/desktop/src-tauri/src/locale/letter.rs |
| Validation + ATS gate | apps/desktop/src-tauri/src/validate/ |
| IPC contract | packages/shared/src/ipc/contracts/documents.ts |
| Output languages (renderer SSOT) | apps/desktop/src/renderer/lib/generate/locales.ts |
| CJK detection (UI notice gate) | packages/shared/src/language-detection.ts (isCjkLanguage) |
A resume is rendered from a single canonical DocumentModel (header + titled
sections of paragraphs / bullets / entries with rich-text runs). Backends
translate the model; they never re-parse text:
resume text ──adapter──▶ DocumentModel ──▶ Typst engine ──▶ PDF (fixed pages)
└──▶ model_docx ──▶ DOCX (Word reflow)
TXT = stripped markdown
The two backends are asymmetric by design and this is intentional:
- PDF is a fixed backend — the Typst engine (
typst_engine/) compiles.typtemplate sources (embedded at build time viainclude_str!) with aResumeWorldoffline world, producing deterministic paginated bytes. No network, no disk access at runtime. - DOCX is a flow backend — it emits paragraphs / a borderless table and
lets Word measure, wrap, and paginate.
keepNext/keepLineskeep headings with their content and bullets intact.
engine.rs and render.rs are the only files that import the typst and
typst_pdf crates. No typst or typst_pdf types appear in any pub signature
outside typst_engine/ — callers only see AppResult<Vec<u8>>. This keeps the
typst dependency ring-fenced behind the adapter.
The shared layer is DocumentModel + Theme + LocaleProfile + section routing
— not a shared paginator.
The resume text parser (parse_line in export/parser/mod.rs) classifies lines
beginning with 1–6 # markers plus an ASCII space (# , ## , ### , …) as
section headers with LineKind::SectionHeader, stripping the marker and preserving
inline markdown marks (**bold**). This means any line the WYSIWYG editor emits
as ## Custom Section will always render as a section heading, independent of
whether the text matches a known section name (SECTION_NAMES) or is ALL-CAPS.
The rule is additive: known-section, ALL-CAPS, thematic-break (---), and job-entry
heuristics are unchanged. A bare # with no heading text falls through to blank.
Significance for the editor↔export contract: The WYSIWYG editor's markdown
serializer (packages/ui/src/components/RichTextEditor/markdown.ts) emits user-created
h2/h3 nodes as ## text / ### text lines. The parser's ATX rule guarantees they render
as sections. The significant-whitespace preservation (job-entry date alignment, e.g.
Senior Engineer␣␣Jan 2020) is a separate round-trip invariant; see the no-drift
gate in markdown.roundtrip.test.ts.
TemplateId (kebab-case on the wire) in export/types.rs. Unknown / removed IDs
(e.g. a saved "modern", or a stale frontend sending "two-column" /
"refined-executive") are silently mapped to Classic via the custom Deserialize
impl — a stale id degrades gracefully rather than breaking export. "modern"
deserializes to Classic forever (the template was removed; its palette is now
reachable via the Document accent — see ADR 0007).
The Tier column is TemplateTier (ats | design) — see Template tier.
Character one-liners are descriptive; the authoritative palette/font/size literals
live in each Template::* constructor under export/templates/ (one module per group).
| Id | Name | Tier | Layout | Character | Best for |
|---|---|---|---|---|---|
classic |
ATS Classic | ats | Single column | Black, no color, ruled headings; plain links | Maximum ATS safety; finance / legal / public |
swiss-minimal |
Swiss Minimal | ats | Single column | Manrope geometric sans, red accent, generous whitespace | Design-adjacent / product |
academic |
Academic | ats | Single column | Full Source Serif 4, forest-green ruled headings, formal | Academia / research |
meridian |
Meridian | ats | Single column | Full-width tinted header band, copper accent, airy body | Creative / modern professional |
throughline |
Throughline | ats | Single column | Vertical timeline spine per experience/project entry | Engineering / product; tenure-story emphasis |
cadence |
Cadence | ats | Single column | Inter, 28pt name, letter-spaced all-caps ruled headings, underlined links, blue-grey accent | Modern & parser-safe; software / product |
regent |
Regent | ats | Single column | Source Serif 4, burgundy small-caps headings, rose rule, first-line-indent letter | Executive / leadership roles |
cologne-navy |
Cologne Navy | ats | Single column | Centred tracked-caps navy header, rule-underlined uppercase headings, blue company names | European market; formal executive |
jake |
Jake | ats | Single column | Ultra-minimal single column, centred name, thin ruled headings, compact entry lines | LaTeX classic aesthetic; print-first |
atelier |
Atelier | design | Two column | Shaded sidebar rail, slate-indigo accent, serif headings | Design; skills-forward |
portrait |
Portrait | design | Two column | Circular photo top-left, name/title right, slate-teal keyline | European market; personal brand |
lebenslauf |
Lebenslauf | design | Single column | DACH DIN-style tabular, photo top-right, formal A4 | German-speaking market |
aria |
Aria | design | Two column | Untinted RIGHT sidebar, rectangular top-right photo, 30pt Manrope name, slate accent | Minimalist personal brand |
saffron |
Saffron | design | Two column | Tinted LEFT sidebar, circular ringed photo, terracotta accent, serif small-caps headings | Warm personal brand |
awesome |
Awesome | design | Single column | Thin accent-tinted header band, accent-bar section markers, single column body | Vibrant personal brand; design-forward |
deedy |
Deedy | design | Single column | Bold name block with accent-colored surname, generous section spacing, subtle grey metadata | Modern personal brand; visual emphasis |
Nine ATS-tier single-column templates (classic, swiss-minimal, academic,
meridian, throughline, cadence, regent, cologne-navy, jake) and seven design-tier templates
(atelier, portrait, lebenslauf, aria, saffron, awesome, deedy — photo, two-column
and/or decorative colour; awesome and deedy are single-column with no photo, and earn the
tier through the colour they drop under ATS mode).
All sixteen route through engine.rs's TypstTemplate::from_template (an exhaustive
match — no fallback). Six render through the parametric single_column.typ with no
bespoke .typ at all, styled entirely from their registry palette via data.style:
classic, swiss-minimal, academic, cadence, regent, jake.
The other ten each have their own .typ under export/typst_engine/templates/:
meridian, throughline, atelier, portrait, lebenslauf, aria, saffron,
cologne-navy, awesome, deedy.
Adding a template is localized and additive: one TemplateId variant + one
Template::* constructor (with its tier) in a group module under export/templates/ + a .typ
source (or a parametric-single_column.typ route). The backends, validation, and
locale logic consume it unchanged.
TemplateTier { Ats, Design } (export/templates/mod.rs) is metadata only — no
render behavior. It does two things:
- Groups the gallery. The picker splits templates into ATS-Safe and Design
sections with a per-card badge; the frontend registry mirrors the Rust
tierandisDesignTier()(renderer/lib/generate/templates/templates.ts) is the gate. - Picks the ATS-toggle set. The ATS-mode toggle (and the recommendation
auto-apply) surfaces for design-tier templates. This replaced the old
isTwoColumnTemplategate — the fix that makes the photo-bearing single-columnLebenslauf(design tier, not two-column) correctly surface the toggle and drop its photo in ATS mode (lebenslauf.typalready honorsis-ats).
Turning ATS mode on for a design-tier template linearizes it (two columns collapse to one, photo dropped) — the honesty the tier advertises. ATS-tier templates are already parser-safe and don't need the toggle.
Both formats must drop the same things. Awesome's résumé DOCX approximates its
PDF header band with paragraph-level shading in a pale tint of the accent, keeping the
normal dark ink — never white text on a raw-accent run, which disappears wherever run
shading is ignored. The tint itself is docx::band_tint_hex (shared with the Banded
cover letter so the two can't drift); it is applied in model_docx::add_header, which
takes ats_mode so the DOCX drops the band exactly when awesome.typ drops it. Which
templates are banded is theme::has_header_band — the same owner as theme::is_two_column,
so PDF and DOCX can't disagree on the roster. Read those three for the exact lightening
amount and roster; don't copy them here.
A Document accent is an optional per-export hex (#RRGGBB or bare RRGGBB) that
recolors the chosen template's accent — the seam that replaced the removed Modern
"same layout, different color" template. It is passed as accent? on the export
request, is not persisted, and never reads ThemePrefs (distinct from the
app-UI accent of ADR 0004).
Omitted (the default) leaves the template palette untouched; a malformed value is
ignored. One validator — typst_engine::normalise_accent — backs every render path:
the résumé PDF threads the hex through RenderOpts.accent, while the cover-letter and
DOCX paths recolor via Template::with_accent_override (whose parse_accent_rgb
delegates to the same normalise_accent), so PDF and DOCX never disagree on validity.
IMPORTANT — the accent overrides each template's accent role, so the recolored surface legitimately differs per template family and per format. On single-column templates the accent is chiefly the link color; on premium templates it's headings / bands. Across formats, DOCX applies the override to emphasis runs (
emphasis_color). The same hex therefore lands on different surfaces depending on template and format — this is intended, not drift. (DOCX link-alignment with the accent is a follow-up candidate.)
Page geometry comes from the request's locale (LocaleProfile::get), resolved to
one source of truth read by every backend:
- US market → US Letter (215.9 × 279.4 mm).
- Every other market and the omitted default → A4 (210 × 297 mm).
The recommender derives the locale from the job ad (explicit target country wins,
then an en-US / en-GB region subtag, then the language). PDF and DOCX always
agree on the page size for a given request.
atsMode makes the document parser-safe:
- The model is linearized (
transform::linearize) into a single canonical reading order, and two-column templates collapse to one column. - The DOCX backend therefore emits no table in ATS mode; the Typst engine lays out a single column.
ATS mode is the answer to position-based parsers (e.g. some modern ATS) that can still interleave a visually two-column PDF. The recommender suggests it for conservative fields.
Atelier, Portrait, Aria, and Saffron are the two-column templates.
Section → column assignment is the canonical theme::placement_for(template_id, section) decision (single source of truth — not a per-template string list). It is
template-aware: the default table plus per-template overrides.
- Default sidebar: Skills, Education, Languages, Certifications.
- Default main: everything else (Summary, Experience, Projects, custom sections).
- Overrides (one centralized id-aware function): Aria pulls Education back
into the main column; Saffron pulls Certifications into the main column.
Atelier/Portraituse the default table unchanged.
theme::is_two_column(id) is the authoritative boolean gate.
The header (name + contact) always spans the full width above the columns.
- PDF: handled inside the respective
.typtemplate; each two-column template manages its own sidebar band and column flows entirely within Typst. - DOCX: a borderless, single-row two-cell table — a shaded sidebar cell
(
Shading.fill= the template tint) + a main cell, fixed layout, borders cleared — so Word flows and paginates it.
render_letter_pdf in typst_engine/engine.rs compiles a finished cover-letter
text through the .typ source chosen by the request's Letter layout
(letter_source dispatches Classic → letter.typ, Refined →
letter_refined.typ, Banded → letter_banded.typ). The letter is not
template-specific; instead it inherits visual styling from the chosen résumé
template via letter_style_from_template / style_from_template (in
typst_engine/letter.rs), whose LetterStyle carries the accent color, body/name
fonts, and font sizes from the résumé Template registry entry. It is
market-aware: letter::conventions (from locale/letter.rs) provides
LetterMarketConventions (date placement, recipient block position, sign-off
style) derived from the job ad's detected locale.
The letter undergoes text completion in export/commands/mod.rs::validate_and_normalize
via complete_letter_text (export/letter_shape.rs). This is the export-time boundary
where the application synthesizes missing market-specific parts:
Input shape: The pipeline and generation stages produce a body-only letter — three to five paragraphs of prose with optional leading subject line and/or date (per market conventions). No salutation, no sign-off, no signature block.
Contract rules:
- The cover letter generation prompt (
pipeline::resume::prompts::letter_system) explicitly instructs the model: "Do NOT write a contact header, a salutation line, or a signature block — the application adds them at export time." This is a hard fence between generation (model-owned body) and export (app-owned furniture). - The export completion function detects whether the letter already carries salutation and sign-off (when re-exporting or from alternate sources). If both are present, no completion occurs (idempotency guard).
- If missing,
complete_letter_textinserts:- Salutation (after any leading subject/date) — resolved from
conventions(market), e.g. "Dear Hiring Manager," (US) or "Sehr geehrte Damen und Herren," (DE). - Sign-off (at document end) — resolved from market conventions, e.g. "Sincerely," or "Mit freundlichen Grüßen".
- Signature name (after sign-off) — resolved by
resolve_candidate_name(export/commands/mod.rs):meta.candidate_name(trimmed, non-blank) first, falling through tocontact.full_name(trimmed, non-blank), else blank.generate_filenameroutes through the same helper so the letter sign-off and the downloaded filename can never disagree on whose name wins.
- Salutation (after any leading subject/date) — resolved from
Why this matters: The prompt says the app will add these parts; if the generation stage ever emits a full letter (salutation + body + sign-off), the parser stops classifying the salutation as furniture and mis-categorizes it as body text — the letter renders with duplicated salutation (once in the letterhead model, once in the body). The completion function is the only place that synthesizes these parts; it is called on every preview render and every export, and it is idempotent (already-complete letters pass through unchanged).
parse_cover_letter in typst_engine/letter.rs splits the completed text into
LetterModel fields (letterhead / date / recipient / subject / salutation / body /
signoff / signature). The model is serialised to JSON and injected via the Typst
virtual data.json — no user content is ever concatenated into Typst markup
(injection-safe).
LetterLayout (export/types.rs, wire field letterLayoutId) selects the letter's
arrangement only — it is orthogonal to the résumé template. Six layouts:
| Layout | Source | Arrangement |
|---|---|---|
classic |
letter.typ |
The original single-letter arrangement. Default — a request omitting the field is byte-identical. |
refined |
letter_refined.typ |
Olivia-Wilson minimalist: large sans name + role top-left, right-aligned contact, rule, always-visible job-reference line (from subject), spaced signature. |
banded |
letter_banded.typ |
Belinda-Davidson: angled pale accent band across page 1 (decorative, behind text), serif small-caps name, stacked right contact, short rule footer. |
navy |
letter_navy.typ |
Centred letterhead (tracked-caps name over contact line) with a navy-weight rule beneath; pairs with the Cologne Navy résumé's centred ruled header. |
sidebar |
letter_sidebar.typ |
Tinted full-height contact rail in a widened left margin, carrying name / role / contact block; body beside it. Styled to pair with sidebar résumés (Atelier, Aria, Saffron, Deedy). Note: not window-envelope compatible (DIN 5008 Form B); rail decorative and drops under ATS mode. |
monogram |
letter_monogram.typ |
Accent initials device beside name / role / contact lockup, full-width rule beneath, body below. Styled to pair with bold-header résumés (Awesome, Jake, Throughline). Device is decorative and drops under ATS mode. |
Inheritance rule. A layout owns arrangement; the palette and fonts always
inherit from the résumé template (via LetterStyle). A layout can never introduce
its own color story — pick Regent and any layout renders in burgundy.
Conventions own semantics. Market conventions (data.opts from
LetterMarketConventions) own the WHAT/WHERE — date position, subject line — and win
where they conflict with a layout's arrangement (e.g. DE DIN keeps the date top-right
in every layout). The governing rule: letter layouts gate structural elements on
data.opts conventions, never on the layout id — composition and semantics stay
separated, so a new layout can't silently diverge a market convention.
Decorative elements drop under ATS mode — the rail (Sidebar) and monogram initials (Monogram) are rendering-only and carry no semantic information, so they are omitted when ats_mode is true, leaving the core letterhead and body intact.
DOCX approximates the PDF layouts (export/docx/mod.rs). Classic keeps the
original, unmodified DOCX renderer (byte-identical). Refined / Banded share a new
DOCX path that approximates the vector design: Banded's angled polygon becomes
flat accent-tinted paragraph shading on the name, PDF small-caps become
uppercase, and the contact block is right-aligned with a bottom-border footer rule. Navy, Sidebar, and Monogram follow similar approximation rules per layout.
Regent small-caps caveat (PDF). Regent and Saffron wrap headings in Typst
smallcaps(…), but the bundled Source Serif 4 lacks the smcp OpenType feature,
so PDF small-caps are currently visually inert (headings render in regular case,
extraction-safe) pending a font-asset swap. DOCX renders them as uppercase.
The AI-Generate template picker surfaces visual preview thumbnails for the
cover-letter rendering, one per resume template. These previews are generated
offline by the generate_cover_template_previews test (ignored, run via
cargo test --lib -- --ignored generate_cover_template_previews) in
export/typst_engine/tests/showcase_letter_previews.rs.
Each preview:
- Renders a sample cover letter (US locale, English, reusing
LETTER_FIXTURE_US) through every résumé template via the exact sameResumeWorld+ Typst compilation path asrender_letter_pdfproduction code. - Applies the template's visual style (accent, fonts, name_pt/body_pt) via
letter_style_from_template. - Exports page 1 as SVG (vector, zero rasterisation) to
apps/desktop/src/renderer/features/ai-generate/assets/cover-template-previews/<slug>.svg. - Is consumed by the renderer's
COVER_TEMPLATE_PREVIEWSVite glob (insamples/cover-template-previews.ts), which emits lazy-loaded hashed URLs. - Mirrors the existing resume
generate_templates_showcase_bannertest (which generates PNG previews underassets/template-previews/).
The test is a hard-wall isolation: typst and typst_svg crates stay
confined to the test function; no typst types appear in production code paths.
typst-svg is a dev-dependency, never shipped.
Dev note — preview-regen. The two preview generators are #[ignore]
libtest fns (generate_templates_showcase_banner and
generate_cover_template_previews in export/typst_engine/tests/). Local cargo test works
unconditionally — the delay-load fix that used to be branch-dependent lives in
apps/desktop/src-tauri/build.rs on main (grep DELAYLOAD). The sha that
caveat named stopped resolving in an earlier history rewrite and went unnoticed,
which is the argument for naming the file rather than the commit. Preview
assets may still be stale/incomplete: cadence, regent, aria,
and saffron may lack .svg (résumé + cover), the classic preview
may still reflect older classic.typ, and the résumé showcase banner may show the old nine. Regenerate in CI or on
a dev host with the fix, and commit the refreshed template-previews/ and
cover-template-previews/ — those two are bundled into the renderer, so they
belong in the tree.
The showcase banner does not. It is a 1.4 MB PNG that only the README
embeds, so it lives on the parentless assets branch and is served from
raw.githubusercontent.com; committing it here would write a fresh copy into
history on every regeneration. Publish a new one without leaving main:
# Stage the one file in a scratch index so main's index is untouched.
export GIT_INDEX_FILE=$(mktemp -u) # -u: a PATH, not a file — git rejects an empty index
git update-index --add --cacheinfo 100644 "$(git hash-object -w path/to/templates-showcase.png)" templates-showcase.png
tree=$(git write-tree)
unset GIT_INDEX_FILE
# Parentless commit, force-pushed: the branch keeps no history by design.
commit=$(git commit-tree "$tree" -m 'chore: publish README-embedded images')
git push --force origin "$commit":refs/heads/assetsVerify with git ls-tree $(git fetch --depth 1 origin assets && echo FETCH_HEAD),
never by loading the CDN URL — raw.githubusercontent.com serves a stale 404 for
minutes after a push.
The AI-Generate wizard displays a real-time preview of the resume/cover letter as the user edits the raw text. The preview renders the exact same Typst document as the final export — no approximation, no drift.
Backend: documents_render_preview_images in export/commands/mod.rs parses the request,
compiles the Typst template with the same DocumentModel + ResumeWorld as the export path,
and emits per-page SVG strings (via render_resume_svg_pages / render_letter_svg_pages in
export/typst_engine/render.rs). The validation gate is omitted for the preview (validation is
redundant; the preview and export follow the same pipeline up to the final emit).
Frontend: renderDocumentPreview() in apps/desktop/src/renderer/lib/generate/export/export.ts
calls the backend, XML-escapes stray & characters in SVG link hrefs (Typst leaves them raw for
performance; invalid XML unless escaped), wraps each SVG string in a Blob with type
image/svg+xml, and returns per-page blob: URLs.
UI: PdfPreview in apps/desktop/src/renderer/components/generation/PdfPreview/
renders a scrollable container of <img src=blob:> elements, one per page, with Blob URL
lifecycle management (revoke on each render batch and on unmount). The preview debounces
~500 ms after same-document edits and re-renders immediately on document switches (résumé ↔
cover letter).
Rationale: See ADR-012. SVG via <img> is no-script, no-fetch, safe for backend-produced
vector, and requires no CSP frame-src (only the existing img-src 'self' blob:). The preview
is the authoritative output before download — template changes in Typst automatically appear in
the preview, and any future export changes are reflected immediately.
The ContactProfile.photo field carries an optional candidate photo used by the
photo templates — Portrait, Lebenslauf, Aria, and Saffron (all design tier;
the photo is dropped when ATS mode linearizes them). Only
data:image/<mime>;base64,<payload> URIs are accepted. File paths are rejected unconditionally by resolve_photo in
typst_engine/photo.rs — there is no path-traversal surface from IPC.
Additional safety measures in photo.rs:
- Raw input capped at 10 MB before decoding.
- Only raster formats accepted (PNG/JPEG/WebP/GIF); SVG and HDR rejected.
- Longest edge downscaled to at most 1 200 px before re-encode.
- Output always re-encoded as lossless PNG — strips all EXIF/XMP/ICC metadata.
- All errors swallowed;
resolve_photoreturnsNoneand templates render without a photo rather than failing.
Client-side pipeline (apps/desktop/src/renderer/lib/photo.ts): decodes, square-crops,
downscales, EXIF-strips, and produces a bounded JPEG data URL before the IPC call.
Upload UI lives in ContactProfileForm.
Single source of truth: apps/desktop/src/renderer/lib/generate/locales.ts
(OUTPUT_LANGUAGES, VALID_LOCALES, SupportedLocale, safeLocale).
Supported locales: en, de, fr, es, it, tr, pt, ru, zh, ja, ko.
Generation and DOCX export work for all 11. CJK limitation: The bundled Typst
fonts (Carlito, Inter, Source Serif 4, Manrope) support Latin + Cyrillic only; Chinese, Japanese, and
Korean resumes render with character tofu in PDF and live preview. The Resume
Builder flags CJK languages with an amber warning and a font icon. Follow-up: bundle
Noto Sans CJK to unblock zh/ja/ko PDF/preview rendering.
Contact and body hyperlinks are first-class rich-text runs, rendered as real
clickable links (PDF /Link /URI annotations; DOCX w:hyperlink with the URL in
the relationships part). The visible label is shown, never the raw URL.
theme::link_style per template:
classic→ body color, no underline (maximum parser/printer safety).- all others → accent color + underline.
Four font families (11 faces total) are vendored and embedded via include_bytes!
in the Typst world (typst_engine/world.rs):
| Bundled family | Used by (representative) | License |
|---|---|---|
| Carlito (Calibri-metric-compatible) | classic, lebenslauf, throughline (body), letter fallback |
OFL |
| Inter | meridian, portrait, cadence, and design bodies; broad Latin/Cyrillic |
OFL |
| Source Serif 4 | academic, regent, and serif headings (atelier / saffron) |
OFL |
| Manrope | swiss-minimal, and throughline / aria name + headings |
OFL |
The authoritative per-template mapping is each template's fonts: TemplateFonts
(name / heading / body roles) in export/templates/'s constructor modules — the table above is
representative, not exhaustive. Carlito provides Calibri-metric compatibility so
exported PDFs measure identically to the DOCX Calibri fallback. Cyrillic coverage
comes from Inter (no Noto face is bundled).
CJK (zh/ja/ko) limitation: the bundled fonts do not include CJK glyphs; see Output languages above.
The DOCX backend references widely-available system fallbacks (not embedded); OOXML true embedding is a tracked follow-up.
Every PDF/DOCX export runs through validate::validate_and_fix after rendering:
- Round-trip — the bytes are re-extracted (pdf-extract for PDF, the unzipped
document.xmlfor DOCX) and checked for content survival and sane reading order. - Auto-fix — a two-column layout whose sections interleave when read back is re-exported single-column (ATS-safe) and re-checked.
- Block — only when a critical defect survives auto-fix (e.g. no extractable text at all). Missing name/email/section and single-column order quirks are warnings, never blocks.
The validate gate uses content-based URL checks and reads Typst inline-dict
/Annots (page_annot_dicts) for link-annotation verification. The old
printpdf-era geometric checks (empty_anchor_link, text_baseline_ys) have been
removed.
The report (ok, atsMode, issues, fixed) rides back on the export result.
All PDF exports carry a baseline tag tree — typst-pdf 0.15 (the bundled version)
defaults PdfOptions.tagged = true, so structured tagging is automatic at render time.
This enables screen-reader navigation and text extraction.
A PDF/UA-1-validated structure (certified accessible document format, higher standard) is a future goal — currently blocked: four templates place link-bearing contact blocks in page backgrounds (a PDF/UA compliance violation), which the spike found requires a redesign of those templates' header layout before validation is possible. See the PDF/UA spike verdict for details.
txt is produced client-side: the markdown is stripped of **bold** markers.
No layout, no validation report.