Textract figure detection
Detects non-text artwork the vision pass never emitted an <img> for, using
Amazon Textractβs LAYOUT feature. Module: workers/api/src/services/textract-layout-detector.ts.
Why
Every other figure path requires the vision model to volunteer the figure first:
| Path | Requires |
|---|---|
data-region-pct on an <img> | vision emitted the img and a bbox |
embedded-image-geometry | vision emitted an img to attach a raster to |
image-region-recovery (Haiku) | vision emitted an <img> placeholder |
When the vision pass transcribes a page as pure text and never mentions the artwork, all three
are silently inert. Job 8bab5965 (Weatherford Newspaper 1906) shipped 46KB of HTML with zero
<img> tags and a clean WCAG pass β image-alt reported βno images foundβ, because none were.
Textract LAYOUT is a detector: it segments the page without being told what to look for, so it does not depend on the transcription step noticing anything.
Measured, 2026-09-05
On job 8bab5965 β a 1-bit CCITTFax scan of a dense 1906 broadsheet, the hardest real case we have:
| Detector | Result | Bbox | IoU vs Textract |
|---|---|---|---|
| Vision pass (production) | missed entirely | β | β |
| Claude Haiku 4.5, 6 runs | detected 6/6 | 1.6Γβ2.3Γ too wide | 0.21β0.37 |
| Textract LAYOUT | detected, 87.2% conf | tight | 1.00 (reference) |
Haikuβs recall is fine; its localisation is not. At 1.6β2.3Γ width the crop swallows two columns of body text, shipping an image-of-text (WCAG 1.4.5) β worse than emitting nothing. Higher render resolution made Haiku worse, not better.
Haiku remains correct for localising a figure the model already described
(image-region-recovery.ts). It is not a detector. Textract is not a describer β alt text still
comes from the alt-text pass.
Cost
AnalyzeDocument Layout is $0.004/page (first 1M pages/month, us-east-1; $0.003 above),
no per-call minimum. Verified against the AWS Pricing API on 2026-09-05.
| Option | 1-page doc | 4-page doc | Minimum |
|---|---|---|---|
| Textract Layout | $0.004 | $0.016 | none |
| Marker | $0.010 | $0.024 | $0.01/call |
| Claude Haiku bbox call | ~$0.002 | ~$0.008 | none |
Layout is its own cheap SKU β do not confuse it with AnalyzeDocument Tables ($0.015/page) or
Forms ($0.05/page).
Tables β free signal, deliberately not the Tables feature
LAYOUT_TABLE blocks arrive in the same response as the figures, at no extra cost. The module
returns tableCountByPage so the assembler can notice that the vision pass flattened a table to
<p>/<dl> (a known failure β see the comment at routes/convert.ts:1391).
This is detection only β LAYOUT_TABLE carries a bounding box, not cell structure. Getting
rows, columns, merged cells, and header association requires FeatureTypes: ['TABLES'] at
$0.015/page, 3.75Γ the Layout rate. The cheap path is: use the free count as a tripwire, and only
escalate to TABLES on a page where the counts disagree. Not yet wired.
Enabling β ZDR prerequisite (SATISFIED 2026-09-07)
The prerequisite is an AWS Organizations AI services opt-out policy. AWS AI services, Textract
included, may retain customer content for service improvement unless that policy exists β it is not
the default, and the Bedrock ZDR posture (bedrock:PutAccountDataRetention: none) does not cover
it. The two are separate controls.
Status: attached and verified.
| Policy | ai-services-opt-out (p-90qu1cuzca) |
| Attached to | org root r-sml2 (inherited by all accounts) |
| Effective for | 696081601609 since 2026-09-06 10:33 CDT |
aws organizations describe-effective-policy \ --policy-type AISERVICES_OPT_OUT_POLICY --target-id 696081601609# {"services":{"default":{"opt_out_policy":"optOut"}}}Note that account-level list-policies-for-target returns [] here β it lists only directly
attached policies, and this one is attached at the root. describe-effective-policy is the check
that matters; do not read the empty list as βnot appliedβ.
Flags were turned on 2026-09-07 once that verify passed:
| flag | runtime | where |
|---|---|---|
TEXTRACT_FIGURE_DETECTION=true | Lambda | infra/cdk/lib/stacks/api-stack.ts |
TEXTRACT_FIGURE_DETECTION=true | Node server .4 | .env.node-server |
PDF_TEXTRACT_ENABLED=true | Lambda | infra/cdk/lib/stacks/api-stack.ts |
PDF_TEXTRACT_ENABLED=true | Node server .4 | .env.node-server (pre-existing) |
One document (job 8bab5965) was sent to Textract on 2026-09-05 during evaluation, before the
policy was in place. Tracked as CRM task 628225a8-a139-4396-993c-33471c20a9d6. Do not close
that task because the opt-out policy is now attached: the policy was attached on 2026-09-06 and
does not apply retroactively to a call already made, so the task is the only durable record of
that exposure.
PDF_TEXTRACT_ENABLED above does not control the flyer-tagger OCR lane (#3415). That lane has
its own switch, FLYER_TAGGER_TEXTRACT_ENABLED, deliberately so that an env copy from this file
cannot enable it β see docs/admin/products/pdf/textract-ocr-lane.md.
Deployment notes
- IAM:
textract:AnalyzeDocumentgranted to the API Lambda ininfra/cdk/lib/stacks/api-stack.ts.AnalyzeDocumenthas no resource-level ARN, so'*'is the only expressible resource; the action itself is the scope. - The chunked pipeline runs on the Node server (10.1.1.4), not the Lambda. The IAM grant above
does not cover it.
.4needs AWS credentials withtextract:AnalyzeDocumentin.env.node-server, plus a rebuild. This is the same runtime-parity trap that has bittenMATHJAX_PRERENDER_ENABLEDandPUBLIC_BASE_URLβ a flag set in one runtime and not the other fails silently down the OFF path. - Region defaults to
AWS_REGION, thenus-east-1.
Wired
Called from chunk-assembler.ts (step 4a-v, ~line 1828) via detectFiguresViaTextract, gated by
isTextractDetectionEnabled() and the presence of a browser. It runs before the screenshot
fallback, so a precise ~$0.004/pg crop pre-empts a whole-page raster. Fail-open β a Textract
outage logs a non-blocking warning and never fails an otherwise-good conversion.
The gate (resolved β implemented as recommended below)
textractCandidatePages() implements the gate argued for here: it takes embeddedRectsByPage,
assignedRectsByPage and pagesWithAssignedImage to rule out Case A, plus the complexity pages, and
returns only pages where Textract is the right tool. The reasoning that produced it is kept below
because it is the argument for not widening the gate later.
Two distinct failure cases, with different correct fixes:
Case A β discrete image XObjects went unclaimed. 02_human_anatomy (2 pages, 1 image XObject)
and Sample of cover issue (4 pages, 4 image XObjects) both produced zero-image output on some
runs and correct output on others, from byte-identical input. The raster and its placement
geometry are already in the PDF; embedded-image-geometry.ts exists precisely for this. Textract is
the wrong tool β this should be fixed deterministically, for free, and it appears to be the more
common case.
Case B β artwork is pixels inside a full-page scan. Weatherford. No separate XObject exists, so region-cropping from the page render is the only option, and detection has to come from somewhere. This is what Textract is for.
Gating Textract on Case A would spend money papering over a bug that has a free, exact fix. Recommended gate: page has no unclaimed usable raster (ruling out Case A) and the vision pass emitted zero figures and the page is backed by a full-page raster.