#1170 β Document-like image OCR routing
Goal
When a JPG/PNG upload is a document (scanned page, worksheet, math problem),
produce real OCRβd text + MathML (and, where possible, cropped figures) instead
of the current image-passthrough output (one <img> + a large vision
alt-text blob). Non-document images (photos, logos, charts-as-art) keep the
existing describe-the-whole-image passthrough.
Verified facts (donβt relitigate)
- Image conversions run on the EC2 batch worker via
executePipeline(workers/batch/src/pipeline-executor.ts) βprocessConversion(workers/api/src/routes/convert.ts). They cannot run on Lambda β the test conversionβs own pipeline timer was 376s vs Lambdaβs 300s ceiling. - The image-passthrough branch is
convert.ts:2808-2843(theelseafter the pdf/docx/audio branches). - Mathpix creds are available in this runtime. The batch worker path-loads
/accessible-pdf/shared/*from SSM intoprocess.env(workers/batch/src/index.ts:45,85-86) andprocessConversionreceives them asapiKeys.MATHPIX_APP_ID/apiKeys.MATHPIX_APP_KEY. No infra work needed. - A direct image OCR endpoint already exists:
processImage()(workers/api/src/services/mathpix-pdf.ts:499) β Mathpix/v3/text, returns{ html, mathml?, latex? }. No PDF-wrapping required for the text/math win. /v3/textdoes not return cropped figure images; only/v3/pdfβhtml.zipdoes (mathpix-chunk-converter.ts). Figure crops are a phase 2.
Design
Branch inside the image else block (convert.ts:2808). Decision order:
if (fileType === image): if (hasMathpix && isDocumentLikeImage(...)): result = await processImage(imageData, mime, mathpixConfig, {includeMathml:true}) if (result && isMeaningfulOcr(result)): html = wrapMathpixImageHtml(result, h1Text) # OCR text + MathML resolvedBackend = 'image-mathpix-text' -> downstream pipeline still runs (MathML validation, alt-text for any residual <img>, WCAG fix loop) unchanged else: fall through to image-passthrough # photo / empty OCR else: image-passthrough (current behavior)βDocument-likeβ gate
Cheapest reliable gate, in order of preference:
- Try-and-validate (recommended for phase 1): always call
processImagewhenhasMathpix, then keep the result only ifisMeaningfulOcrβ e.g. stripped text length β₯ N chars OR β₯1 MathML node. A photo/logo returns little or no text and falls back automatically. One Mathpix call (~$0.004β 0.01) is cheaper and faster than the current ~$0.01 vision + 120s polish, so the speculative call is net-positive even when it falls back. - Optional pre-gate to avoid the Mathpix call on obvious non-docs: a quick heuristic (aspect ratio near a page, or a tiny Gemini-flash classify call). Defer unless Mathpix cost on fallbacks proves material at volume (~20 img/day today β negligible).
Output shaping
wrapMathpixImageHtml(result, h1Text):
<h1>{filename-derived}</h1>(keep current heading-one behavior).- Insert
result.html(Mathpix already returns structured HTML; sanitize via the existingsanitizeText/post-processing sanitizers). - MathML from
result.mathmlflows into the existing LaTeXβMathML validators inrunPostProcessing(the image already produced 26 valid MathML tags via the vision path; Mathpix MathML should validate the same way). - Any residual
<img>Mathpix emits still gets alt text fromenhanceImagesInHtmldownstream β compose, donβt replace.
Implementation steps
- Extract a Mathpix config helper in
convert.ts(or reuse the existing one the pdf branch builds) so the image branch can construct{ appId, appKey }fromapiKeys. Confirm whether the pdf branch already hasmathpixConfigin scope to lift. - Add
isMeaningfulOcr(result)small pure helper +wrapMathpixImageHtml(result, h1Text)β colocate near the image branch or in a newservices/image-ocr.ts. Unit-testable in isolation. - Branch in
convert.ts:2808per the design above. Keep TIFFβPNG sharp conversion (2820-2829) ahead of the Mathpix call so unsupported formats are normalized first (Mathpix/v3/textwants png/jpeg/webp/gif data URI). - Set
metadata.resolvedBackendtoimage-mathpix-textvsimage-passthroughand push apipeline_logstep (step: 'image-ocr',detail,estimatedCostUsd) so cost-tracking + the file-detail view reflect the path taken (matches the cost-tracking standard). - Cost tracking: record the Mathpix image call cost (per-image price) the
same way
chunk-processorrecordsmathpix OK ... cost=$. - Progress feedback (covers the issueβs secondary item): emit
updateProgress(..., 'Reading text from image')before the Mathpix call so the UI isnβt stuck on a generic label.
Tests
workers/api/src/__tests__/services/image-ocr.test.ts:isMeaningfulOcrβ empty/whitespace/short β false; real text or MathML β true.wrapMathpixImageHtmlβ emits one<h1>, embeds OCR html, preserves MathML.
convert.tsimage-branch tests (mockprocessImage):- Mathpix returns meaningful OCR β backend
image-mathpix-text, html contains MathML. - Mathpix returns null/empty β falls back to
image-passthrough, no throw. hasMathpix=falseβ straight passthrough (unchanged behavior).
- Mathpix returns meaningful OCR β backend
- Keep coverage β₯ 80%; add the new file to the route/service test mirror.
Phase 2 (separate PR, only if figure crops are needed)
- For document images with embedded diagrams, wrap the (PNG/JPEG) image into a
1-page PDF (pdf-lib
embedJpg/embedPng+drawImage, both available on the batch worker) and run it through the existing/v3/pdfβhtml.zippath (mathpix-chunk-converter.ts) to get tightly-cropped figures. - Gate on βimage contains a figure regionβ to avoid the heavier
/v3/pdfcall for pure text/math images.
Out of scope / follow-ups
- Adding
MATHPIX_APP_ID/KEYto the rotation scriptβsSSM_SECRETSlist (hygiene β rotation currently wonβt update the SSM copy). File separately. - Marker
chart_understanding,infographicextras for the passthrough path (issue option 3) β independent lever.
Code review resolution (high-effort multi-agent review)
- #1 WCAG regression (fixed): OCR output is rejected (β passthrough) when it
contains an
<img>without meaningful alt text (htmlHasImageWithoutAlt), so the OCR path can never ship an un-described image. Figures stay on passthrough until phase 2. - #2 cost double-count (fixed): the
image-ocrpipeline_log step no longer carriesestimatedCostUsd; theconversionstep owns the Mathpix cost. - #3 passthrough XSS (fixed): the passthrough
<h1>now escapes the filename via the canonicalescapeHtml. - #4 classify resilience (fixed): the Gemini classify call is wrapped in
callWithRetry. - #5 altitude β two Mathpix integrations (partial): shared a
mathpixConfigFromApiKeys()helper so the image path builds Mathpix config identically to the PDF paths (deduped 5 sites). Full convergence β routing document images through the batch pipelineβs scanned-document mechanism β is phase 2 (wrap β/v3/pdf), where the figure-crop machinery is shared too. The phase-1 divergence is deliberate: images have no PDF operator stream for the heuristic detector and no PDF for/v3/pdf. - #6 efficiency β sequential classify + OCR (deliberately deferred): the
classify call gates the Mathpix spend and, unlike
isMeaningfulOcr, distinguishes a document from a photo that happens to contain text (which must keep the describe-the-whole-image passthrough). Reordering or parallelizing would either lose that distinction or spend the Mathpix call on photos. A future micro-opt is to downscale the image before the classify call; not worth the sharp coupling + test burden at current volume (~20 img/day). - #7 reuse (fixed):
escapeHtmlandstripHtmlToTextnow come fromutils/html(the latter promoted there and shared withreading-order-verifier).withTimeoutleft local β no canonical shared util exists and extracting one would expand scope intoimage-enhancer. - #8 simplification (fixed): the kill-switch is now
(... ?? process.env.X) !== 'false'with a default-ON comment.
Risk / rollback
- Single, well-contained branch;
hasMathpix=falseand theisMeaningfulOcrfallback both preserve current behavior, so the blast radius is document-like images only. - Add an env/option kill-switch (e.g.
IMAGE_OCR_VIA_MATHPIX=false) to revert routing without a redeploy, mirroring theMARKER_USE_CONVERT_V2pattern.