Translated output for reflow conversions
Offer a Spanish (and later any-language) download of a converted document. Reflow route only β the preserve-design route is pixel-identity-gated and cannot carry translated text by construction.
Status: proposed, not started. No code written.
Why this is tractable
The reflow pipelineβs canonical artifact is HTML; the PDF is a render of it:
PDF β chunks β Claude β assembled accessible HTML (files.outputR2Key) β SQS 'export-accessible-pdf' β WeasyPrint β tagged PDF β pdf-accessibility-postprocessor (/Lang, XMP dc:language) β veraPDF gateSo a Spanish PDF is translate the HTML, then re-run the export that already exists. No new rendering, tagging, or conformance machinery.
Engine decision: Claude via Bedrock, not Amazon Translate
Amazon Translate is sentence-level neural MT β fast, deterministic, HTML-tag aware, glossary-capable. It cannot take instructions, and every requirement here is an instruction:
- keep the tag skeleton byte-identical (drift breaks the PDF/UA gate downstream)
- translate
alttext and keep it descriptive, not literal - wrap untranslated proper nouns in
lang="en"spans (WCAG 3.1.2) - keep table headers semantically aligned with the cells they scope
- leave code blocks, URLs, and email addresses alone
Cost does not argue for Translate either. A 10-page document β 25K characters β 6,250 input tokens; Spanish runs ~20% longer than English, so ~7,800 output tokens. Bedrock bills Claude at Anthropic list rates (confirmed by Larry 2026-09-13):
| Engine | Per 10-page doc |
|---|---|
| Claude Sonnet 5 ($2/$10 per MTok) | ~$0.09 |
| Claude Sonnet 5 via Batch API (50% off) | ~$0.045 |
| Claude Opus 5 ($5/$25 per MTok) | ~$0.23 |
| Claude Opus 5 via Batch API | ~$0.12 |
| Amazon Translate (~$15/M chars β unverified) | ~$0.38 |
Default to claude-opus-5; Sonnet 5 is the obvious A/B, but that call belongs to
an eval, not to this brief. Translation is a queue job with nobody waiting on the
response, so check Batch API availability on our Bedrock path β the discount
is free money given the architecture is already async.
Prompt caching buys little (every document is a unique prefix); only the system prompt and per-tenant glossary are cacheable.
Using Bedrock Claude is also zero new infrastructure: ZDR posture, cost_ledger,
resolveModelId, and budget caps are already wired. Amazon Translate would mean a
new service, new IAM, new cost-tracking, and a separate data-residency review β
for a worse fit.
Work breakdown
1. Translation service (the only genuinely new code)
New workers/api/src/services/document-translator.ts. Chunked Bedrock call over
the assembled HTML. Reuse the existing chunking infra; book a cost_ledger row
(new operation_type='document-translate') per the cost-tracking standard.
Hard gate β tag-skeleton diff. Extract the element sequence (tag names + nesting depth, attributes excluded) before and after translation; a mismatch fails the job rather than shipping. This converts the LLMβs one real disadvantage versus Translate into a caught error, and mirrors the flyer-tagger verify-gate philosophy: absence of a passing check is a failure, never a pass.
Per-tenant glossary in the prompt from day one β institutions have terms that must render consistently (degree names, office names, program titles). Stolen from Translateβs Active Custom Translation, which exists for exactly this reason.
2. lang plumbing β already 90% there
workers/api/src/services/pdf-accessibility-postprocessor.tsalready accepts an arbitrarylangand writes/Lang+ XMPdc:language. No change.workers/batch/src/accessible-pdf-export-executor.ts:195hardcodeslang: 'en'β take it from the message.workers/api/src/services/chunk-assembler.ts:3104hardcodeslanguage: 'en'inrunPostProcessingβ same.AccessiblePdfQueueMessageinworkers/api/src/services/accessible-pdf-export.tsβ addlang.
3. Storage model β the real design decision
Today the artifact slot is singular: accessiblePdfR2Key, accessiblePdfStatus,
accessiblePdfScore, accessiblePdfError, accessiblePdfGeneratedAt
(packages/shared/src/types.ts:164+). A Spanish PDF has nowhere to live.
Recommendation: a new document_translations table (fileId, lang, htmlR2Key,
pdfR2Key, status, score, error, costUsd, createdAt; unique on (fileId, lang)).
Prior art worth reading first: migration 294_output_variants uses exactly this
shape for the Enhance before/after. It does not stretch to cover this β it is
keyed to the three form-conversion job tables (not files), its kind CHECK
allows only 'enhanced', and it stores a PDF key with no status/score/error
lifecycle. Extending it would add several nullable columns used by one kind only.
Next migration number is 302.
4. Export path reuse
Enqueue the existing export-accessible-pdf message against the translated HTML
key with lang: 'es', writing results to the translations row instead of the
files columns. POST /files/:fileId/refresh-pdf
(workers/api/src/routes/files.ts:1456) is the closest existing precedent for a
forced regen and worth reading before writing the new route.
5. UI + billing
Language option on the Downloads menu
(apps/web/src/components/file-detail/ActionMenu.tsx,
apps/web/src/components/dashboard/control-center/FileActions.tsx), status
polling, credit charge. Per the standards: optimistic UI, progress indicator (this
will exceed 2s), all strings through t(), WCAG 2.2 AA on the new controls.
What will bite you
- Images keep their English text. Charts, screenshots, logos with words stay English while the prose is Spanish. Say so in the UI β do not let users discover it.
- Language of parts (WCAG 3.1.2). Untranslated proper nouns need
lang="en"spans, or the Spanish PDF carries an AA failure the English one didnβt. - Machine-translation liability. A Title II entity publishing a Spanish version of an official notice is making a legal representation. Label it machine-translated. This is a natural paid human-review upsell, not a silent auto-feature.
- Reports stay English. The WCAG/conformance report templates hardcode
<html lang="en">β the Spanish deliverable ships an English report unless those are templated too. - Route parity. New routes must be registered on BOTH
index-aws.ts(Lambda) andserver.ts(Node .4), per the standing gotcha.
Estimate
- MVP β Spanish only, reflow only, one extra download button: ~3β5 focused days, most of it in the translation service and the variants table.
- Productized β multi-language, per-language scores/reports, review flow, pricing: ~2 weeks.
Open questions
- Batch API availability on our Bedrock path (changes cost by 50%).
- Current Amazon Translate rate β unverified, and academic unless the engine decision is revisited.
- Which languages after Spanish? Drives whether the glossary is per-tenant per-language or per-tenant only.