Content preparation#
This subject previously had provenance only in frozen ADR history (0004-large-page-chunking.md, 0005-embedded-state-sidecar.md) with no living decision ratified under the topic-keyed decision registry. This update ratifies the first living decision for the subject: PDF content preparation, shipped as an opt-in injected seam, not a bundled parser.
Decision#
ExtractInput.pdfTextExtractor?: PdfTextExtractoris the seam. APdfTextExtractoris a caller-supplied{ extract(bytes) => { text, pageOffsets?, layout?, warnings? } }implementation (sync or async, persrc/types.ts). With this unset,contentType: "pdf"keeps returning the pre-existing typed not-implemented error (PDF_PREP_ERROR, unchanged since0.1.0) — every existing caller's behavior is unaffected by this option's mere existence.- No default PDF implementation, no additional PDF dependency. Traverse
takes on no PDF parsing library (
pdfjs-dist,pdf-parse,unpdf, or any other) as a hard or optional peer dependency for this slice. That is separate from the package's declared content-preparation runtime dependencies:linkedomandturndownimplement the default HTML-to-Markdown path. The core bundles no AI-provider SDK; provider adapters use optional peer SDKs behind provider-specific subpaths. PDF, OCR, and browser/render implementations remain caller-injected seams and are not bundled. A caller that needs PDF parsing supplies the parser it already owns, so this slice adds neither a parser implementation nor a PDF dependency. - Composition, not new chunking logic. With an extractor supplied,
extract()runs it viapreparePdfText()(src/content-prep.ts) and hands the resulting text into the EXISTING, unmodified character-window chunker (prepareAndChunk(text, "text", ...)) — the same offset-correct chunking andchars:<start>-<end>provenance-verification machinery HTML/text already use.chunk.tsrequired zero changes. pdfPageOffsetssidecar, mirroringembedded(ADR 0005).ExtractionResult.pdfPageOffsetsis an optionalnumber[]of each page's 0-based start offset into the same prepared text every proposal'schars:<start>-<end>locator is anchored to.resolvePdfPage(pageOffsets, charOffset)(src/content-prep.ts) resolves a verified locator start offset to a 1-based page number. This is deliberately NOT a new locator scheme (e.g. not"pdf:page:3:chars:120-140") — thechars:<start>-<end>contract (ADR 0001 §4) is unchanged for every content type, including pdf;pdfPageOffsetsis a structured sidecar alongside it, matching howembeddedsits alongside proposals rather than inside them.pageOffsetsis trust-not-verify: unlikeexcerpt, Traverse cannot independently confirm page numbers against real PDF structure. It is shape-validated (finite, non-decreasing, in-range) and dropped, with a warning, if malformed — never silently trusted. A caller with a buggy extractor can still get structurally-valid but semantically-wrong page numbers; this mirrors the pre-existingembeddedsidecar's un-verified-content precedent.- Optional PDF layout remains a sidecar, not a new proposal locator.
PdfExtractedText.layoutcan carry page geometry, typed text elements, and structured tables. Every element and table cell includes an exact UTF-16 range into the extractor'stext;preparePdfText()validates those ranges, page references, boxes, spans, and cell identities, normalizes arrays into deterministic source order, and defensively copies the result. One malformed member drops the whole sidecar with a warning. The validated value is exposed asExtractionResult.pdfLayoutand in the portable result envelope. Review UIs intersect a proposal'schars:<start>-<end>range with these ranges for two-way field/source highlighting.resolvePdfLayoutSpan()owns the exclusive-end overlap and typed invalid-locator behavior so consumers do not reimplement it. Traverse does not infer a box from text or trust a provider-supplied proposal locator. - Coordinate systems are explicit. Each page declares points, pixels, or
normalized coordinates plus its dimensions and optional rotation. Bounds
must fit that page. Parser-native element labels can survive as
providerType, while the small portablekindvocabulary describes only layout role. Tables stay as row/column cells with ranges and optional spans; they are never flattened into plausible prose. - Never-throw preserved.
preparePdfText()wraps the extractor call in try/catch (mirroringhtmlToMarkdown's degrade-via-try/catch discipline); a synchronous throw or a rejected Promise fromPdfTextExtractor.extract()surfaces asExtractionResult.error, never propagated.extract()'s existing top-level try/catch is an unconditional second safety net. - Cost guards compose unchanged.
maxProviderCalls/maxTotalTokens(seeextraction-cost-guard.md) run in the per-chunk provider loop, which the pdf pre-step feeds into identically to html/text — no changes to that loop were needed. prepareContent()'s sync signature does not gain PDF support. Onlyextract()and the new standalonepreparePdfText()support the extractor seam;prepareContent(bytes, "pdf")still always returnsPDF_PREP_ERROR, even with an extractor available elsewhere in a caller's code —prepareContentwas deliberately not given apdfTextExtractorparameter, to avoid a sync-to-async breaking change to a widely-called function. This is a documented asymmetry, not a bug.
Structural HTML keeps content outside the card container#
Structural chunking (ADR 0004 §D2) changes where chunk boundaries fall; it
should not silently remove a page's own text, and it must not let that text
crowd out the cards. The page text outside the detected card container is
converted with the same Turndown configuration as the whole-page path, after a
second chrome pass that the element-name pruning misses: ARIA navigation,
banner, and contentinfo landmarks, blocks with at least three links whose
link text is at least half their text (div navbars, mega-menus, pagination),
and the short label remnants those leave behind. Blocks are judged innermost
first, so a wrapper that mixes a menu with real text keeps the text. A landmark
role inside article or main (or role="main") is scoped to that content and
kept, and tel:/mailto:/sms: links are contact values, not navigation, so
they never make a block link-dense. Every block the pass removes is quoted in a
structural prep pruned … warning (at most five blocks, 60 characters each,
with the total character count), classified on the envelope as
preparation/navigation-pruned.
The element-name pruning before it follows the same rule for all Markdown
prep, structural or whole-page: header, footer and aside inside an
article belong to that article and are kept (and Turndown keeps them too,
even when a card is converted on its own). Everywhere else, including inside
main outside any article, they are page chrome (breadcrumbs, related-item
footers, newsletter asides) and are removed, as are every nav, form and
noscript; each removal is quoted in a markdown prep pruned … warning with
the same bound and classification.
Scripts, styles and other elements without page text are removed without a
warning. Markdown prep therefore never drops page text silently; the legacy
prep: "text" regex strip is unchanged.
What remains is a leading and a trailing segment. A segment of at most a
quarter of chunkSize rides along with the first or last card batch without
changing card boundaries, as the page title always did. A longer segment gets
its own chunks (character window when longer than one chunk), and every card
chunk is emitted, and dispatched, before any outside chunk, so maxChunks,
maxProviderCalls, and maxTotalTokens reach the cards first. Outside chunks
left out by maxChunks produce a warning naming the chunk and character
counts. Chunks are therefore ordered cards first, not by offset. A cards-only
page keeps its chunk boundaries. Pages with text outside the container get
different prepared text, a different digest, and a new preparation version.
Out of scope#
- A page/region proposal locator. Geometry enriches an exact prepared-text
locator through
pdfLayout; it does not replace it with a second locator scheme or claim raw-PDF byte offsets. - Page-boundary-preferred chunk splitting. PDF-aware chunking that never
lets a chunk boundary fall mid-page (mirroring how structural HTML
chunking never splits a card) is a real potential future improvement,
explicitly deferred. The character-window fallback's existing overlap
(
chunkOverlap, default 200 chars) already gives reasonable robustness against a value straddling a chunk boundary, same as it does for HTML today. - The fetch-layer binary-body gap (pre-existing, not fixed here).
src/fetch/fetch-source.tscallsresponse.text()unconditionally for every content type, andSnapshot.bodyis UTF-8-decoded — fetching a real PDF over HTTP viafetchSource/fetchAndExtracttoday silently corrupts the binary bytes before content-prep ever runs. This decision does not touch the fetch layer; the seam is designed for uploaded-file bytes passed directly toextract(), not URL-fetched PDFs. A follow-up issue forfetchSourcebinary/Uint8Arraybody support is recommended before anyone relies onfetchAndExtractfor a PDF URL. - Whether Traverse should ever ship a bundled, optional default PDF
parser (dynamic-import, subpath-gated, e.g. behind
@kontourai/traverse/pdf) once a second PDF-consuming application exists. Left explicitly open, not decided, not silently foreclosed. - No real-parser parity proof in this repo. The end-to-end test coverage
for this decision uses a hand-crafted minimal PDF fixture
(
tests/fixtures/minimal-two-page.pdf) and a test-only, regex-based "naive" extractor (tests/fixtures/naive-pdf-text-extractor.ts) — neither ispdfjs-distor any production parser. This proves the SEAM composes correctly; it does not prove a specific downstream consumer's real parser drops in cleanly. Seedocs/parity-methodology.mdfor how a downstream adoption slice should prove that. - Very large PDFs (accepted gap).
preparePdfText's internal full-text cap mirrors the existingSAFETY_CAP/SHELL_INSPECT_CAP(5,000,000 chars); a pathological multi-thousand-page PDF whose extracted text exceeds that cap could have trailingpageOffsetsentries pointing past the truncated text. Accepted, matching the existingSAFETY_CAPframing elsewhere in this package.