Skip to content

Latest commit

 

History

History
541 lines (421 loc) · 33.7 KB

File metadata and controls

541 lines (421 loc) · 33.7 KB

Export Templates — the resume/cover-letter rendering contract

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)

Architecture

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 .typ template sources (embedded at build time via include_str!) with a ResumeWorld offline 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 / keepLines keep headings with their content and bullets intact.

Typst adapter isolation boundary

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.

Markdown ATX headings (user-created custom sections)

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.


The sixteen templates

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.

Template tier & ATS-mode toggle

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 tier and isDesignTier() (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 isTwoColumnTemplate gate — the fix that makes the photo-bearing single-column Lebenslauf (design tier, not two-column) correctly surface the toggle and drop its photo in ATS mode (lebenslauf.typ already honors is-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.

Document accent (per-export color knob)

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 size & locale

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.


ATS mode

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.


Two-column layout

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 / Portrait use 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 .typ template; 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.

Cover-letter PDF

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.

Letter text completion contract

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_text inserts:
    • 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 to contact.full_name (trimmed, non-blank), else blank. generate_filename routes through the same helper so the letter sign-off and the downloaded filename can never disagree on whose name wins.

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).

Letter layouts

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.

Cover-letter template previews (AI-Generate UI)

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 same ResumeWorld + Typst compilation path as render_letter_pdf production 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_PREVIEWS Vite glob (in samples/cover-template-previews.ts), which emits lazy-loaded hashed URLs.
  • Mirrors the existing resume generate_templates_showcase_banner test (which generates PNG previews under assets/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/assets

Verify 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.


Live preview (AI-Generate)

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.


Candidate photo

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_photo returns None and 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.


Output languages (11 supported)

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.


Links

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.

Fonts

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.


Validation + ATS gate

Every PDF/DOCX export runs through validate::validate_and_fix after rendering:

  1. Round-trip — the bytes are re-extracted (pdf-extract for PDF, the unzipped document.xml for DOCX) and checked for content survival and sane reading order.
  2. Auto-fix — a two-column layout whose sections interleave when read back is re-exported single-column (ATS-safe) and re-checked.
  3. 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.


Accessibility & tagged PDF

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

txt is produced client-side: the markdown is stripped of **bold** markers. No layout, no validation report.