// Simple numbering for non-book documents #let equation-numbering = "(1)" #let callout-numbering = "1" #let subfloat-numbering(n-super, subfloat-idx) = { numbering("1a", n-super, subfloat-idx) } // Theorem configuration for theorion // Simple numbering for non-book documents (no heading inheritance) #let theorem-inherited-levels = 0 // Theorem numbering format (can be overridden by extensions for appendix support) // This function returns the numbering pattern to use #let theorem-numbering(loc) = "1.1" // Default theorem render function #let theorem-render(prefix: none, title: "", full-title: auto, body) = { if full-title != "" and full-title != auto and full-title != none { strong[#full-title.] h(0.5em) } body } // Some definitions presupposed by pandoc's typst output. #let content-to-string(content) = { if content.has("text") { content.text } else if content.has("children") { content.children.map(content-to-string).join("") } else if content.has("body") { content-to-string(content.body) } else if content == [ ] { " " } } #let horizontalrule = line(start: (25%,0%), end: (75%,0%)) #let endnote(num, contents) = [ #stack(dir: ltr, spacing: 3pt, super[#num], contents) ] #show terms.item: it => block(breakable: false)[ #text(weight: "bold")[#it.term] #block(inset: (left: 1.5em, top: -0.4em))[#it.description] ] // Some quarto-specific definitions. #show raw.where(block: true): set block( fill: luma(230), width: 100%, inset: 8pt, radius: 2pt ) #let block_with_new_content(old_block, new_content) = { let fields = old_block.fields() let _ = fields.remove("body") if fields.at("below", default: none) != none { // TODO: this is a hack because below is a "synthesized element" // according to the experts in the typst discord... fields.below = fields.below.abs } block.with(..fields)(new_content) } #let empty(v) = { if type(v) == str { // two dollar signs here because we're technically inside // a Pandoc template :grimace: v.matches(regex("^\\s*$")).at(0, default: none) != none } else if type(v) == content { if v.at("text", default: none) != none { return empty(v.text) } for child in v.at("children", default: ()) { if not empty(child) { return false } } return true } } // Subfloats // This is a technique that we adapted from https://github.com/tingerrr/subpar/ #let quartosubfloatcounter = counter("quartosubfloatcounter") #let quarto_super( kind: str, caption: none, label: none, supplement: str, position: none, subcapnumbering: "(a)", body, ) = { context { let figcounter = counter(figure.where(kind: kind)) let n-super = figcounter.get().first() + 1 set figure.caption(position: position) [#figure( kind: kind, supplement: supplement, caption: caption, { show figure.where(kind: kind): set figure(numbering: _ => { let subfloat-idx = quartosubfloatcounter.get().first() + 1 subfloat-numbering(n-super, subfloat-idx) }) show figure.where(kind: kind): set figure.caption(position: position) show figure: it => { let num = numbering(subcapnumbering, n-super, quartosubfloatcounter.get().first() + 1) show figure.caption: it => block({ num.slice(2) // I don't understand why the numbering contains output that it really shouldn't, but this fixes it shrug? [ ] it.body }) quartosubfloatcounter.step() it counter(figure.where(kind: it.kind)).update(n => n - 1) } quartosubfloatcounter.update(0) body } )#label] } } // callout rendering // this is a figure show rule because callouts are crossreferenceable #show figure: it => { if type(it.kind) != str { return it } let kind_match = it.kind.matches(regex("^quarto-callout-(.*)")).at(0, default: none) if kind_match == none { return it } let kind = kind_match.captures.at(0, default: "other") kind = upper(kind.first()) + kind.slice(1) // now we pull apart the callout and reassemble it with the crossref name and counter // when we cleanup pandoc's emitted code to avoid spaces this will have to change let old_callout = it.body.children.at(1).body.children.at(1) let old_title_block = old_callout.body.children.at(0) let children = old_title_block.body.body.children let old_title = if children.len() == 1 { children.at(0) // no icon: title at index 0 } else { children.at(1) // with icon: title at index 1 } // TODO use custom separator if available // Use the figure's counter display which handles chapter-based numbering // (when numbering is a function that includes the heading counter) let callout_num = it.counter.display(it.numbering) let new_title = if empty(old_title) { [#kind #callout_num] } else { [#kind #callout_num: #old_title] } let new_title_block = block_with_new_content( old_title_block, block_with_new_content( old_title_block.body, if children.len() == 1 { new_title // no icon: just the title } else { children.at(0) + new_title // with icon: preserve icon block + new title })) align(left, block_with_new_content(old_callout, block(below: 0pt, new_title_block) + old_callout.body.children.at(1))) } // 2023-10-09: #fa-icon("fa-info") is not working, so we'll eval "#fa-info()" instead #let callout(body: [], title: "Callout", background_color: rgb("#dddddd"), icon: none, icon_color: black, body_background_color: white) = { block( breakable: false, fill: background_color, stroke: (paint: icon_color, thickness: 0.5pt, cap: "round"), width: 100%, radius: 2pt, block( inset: 1pt, width: 100%, below: 0pt, block( fill: background_color, width: 100%, inset: 8pt)[#if icon != none [#text(icon_color, weight: 900)[#icon] ]#title]) + if(body != []){ block( inset: 1pt, width: 100%, block(fill: body_background_color, width: 100%, inset: 8pt, body)) } ) } // arxivai — Quarto Typst template partial. // Replaces Quarto's default `article()` with the arxivai preprint layout. // Both packages are importable from raw ```{=typst}``` blocks in the body: // #arxivai.operation-box(...) #arxivai.app-figure(...) #lettreai.badge(...) #import "lettreai.typ" as lettreai #import "arxivai.typ" as arxivai // --------------------------------------------------------------------------- // Re-theme Quarto's automatic callouts (same contract as lettreai: block // nesting kept structurally identical to Quarto's default so the crossref // figure show rule in definitions.typ keeps working). // --------------------------------------------------------------------------- #let callout( body: [], title: "Callout", background_color: rgb("#dddddd"), icon: none, icon_color: black, body_background_color: white, ) = { let accent = if icon_color == black { lettreai.colors.oxford-blue } else { icon_color } block( breakable: false, fill: lettreai.tint(accent, 0.94), stroke: (left: 3pt + accent, rest: 0.5pt + lettreai.tint(accent, 0.5)), width: 100%, radius: 3pt, block( inset: 1pt, width: 100%, below: 0pt, block( fill: lettreai.tint(accent, 0.84), width: 100%, inset: 8pt, )[#if icon != none [#text(accent, weight: 900)[#icon] ]#text(fill: lettreai.shade(accent, 0.25), weight: 700)[#title]]) + if (body != []) { block( inset: 1pt, width: 100%, block(fill: body_background_color, width: 100%, inset: 8pt, body)) } ) } // --------------------------------------------------------------------------- // arXiv preprint article — same signature contract as Quarto's default, // extended with keywords and running-title. // --------------------------------------------------------------------------- #let article( title: none, subtitle: none, authors: none, keywords: (), running-title: none, book: false, date: none, abstract-title: none, abstract: none, thanks: none, cols: 1, lang: "en", region: "US", font: none, fontsize: 10.5pt, title-size: 1.7em, subtitle-size: 1.15em, heading-family: none, heading-weight: "bold", heading-style: "normal", heading-color: black, heading-line-height: 0.65em, mathfont: none, codefont: none, linestretch: 1, sectionnumbering: none, linkcolor: none, citecolor: none, filecolor: none, toc: false, toc_title: none, toc_depth: none, toc_indent: 1.5em, doc, ) = { // PDF metadata. set document(title: title) set document( author: authors.map(a => lettreai.to-string(a.name)).join(", ", last: " & "), ) if authors != none and authors != () set document( keywords: keywords.map(k => lettreai.to-string(k)), ) if keywords != none and keywords != () set par(justify: true, leading: linestretch * 0.65em) set text(lang: lang, region: region, size: fontsize) show math.equation: set text(font: mathfont) if mathfont != none set heading(numbering: sectionnumbering) // Running header + footer; book mode gets chapter-aware heads and the // O'Reilly outer-margin geometry. let rt = if running-title != none { running-title } else if title != none { title } else { [] } set page( header: if book { arxivai.book-header() } else { arxivai.arxiv-header(rt) }, footer: if book { arxivai.book-footer() } else { arxivai.arxiv-footer() }, ) set page(margin: arxivai.book-margin) if book // Resolve fonts: explicit Quarto fonts win, else theme defaults. let resolved-font = if font != none { font } else { lettreai.body-font } let resolved-code = if codefont != none { codefont } else { lettreai.mono-font } let resolved-heading-color = if heading-color != black { heading-color } else { lettreai.colors.oxford-blue } let resolved-link = if linkcolor != none { rgb(lettreai.to-string(linkcolor)) } else { lettreai.colors.oxford-blue } // Paint everything with the shared theme, then the figure rules. show: lettreai.theme-rules.with( fonts: resolved-font, code-font: resolved-code, heading-color: resolved-heading-color, link-color: resolved-link, ) show: arxivai.arxiv-figure-rules // Book chapter rule is the innermost show, so it transforms level-1 // headings FIRST (inner rules apply first), replacing them with chapter // openers before the theme's h1 rule could wrap them in a container. // Synthetic non-outlined headings (e.g. the outline's own title) are // excluded inside book-heading-rules. show: doc2 => if book { arxivai.book-heading-rules(doc2) } else { doc2 } show ref: set text(fill: rgb(lettreai.to-string(citecolor))) if citecolor != none // Preprint front matter. arxivai.arxiv-title-block( title: title, subtitle: subtitle, authors: authors, date: date, ) if abstract != none { arxivai.arxiv-abstract( abstract, abstract-title: if abstract-title == none { [Abstract] } else { abstract-title }, ) } arxivai.arxiv-keywords(keywords) // Book mode: the front matter is a title page of its own. if book { pagebreak(weak: true) } // Optional table of contents (its own page in book mode). if toc { block(above: 0.5em, below: 2em, lettreai.lettreai-outline( title: if toc_title == none { auto } else { toc_title }, depth: toc_depth, indent: toc_indent, )) if book { pagebreak(weak: true) } } if cols > 1 { columns(cols, doc) } else { doc } } #let brand-color = (:) #let brand-color-background = (:) #let brand-logo = (:) #set page( paper: "a4", margin: (x: 2.4cm,y: 2.6cm,), numbering: "1", columns: 1, ) #show: doc => article( title: [PDAI: A Certified Document Infrastructure for Computational Historical Scholarship], subtitle: [From PDF to a Queryable, Audited, and Learning-Enabled Corpus], authors: ( ( name: [Gerd Graßhoff], affiliation: [Humboldt-Universität zu Berlin, Deutsches Museum], email: [gerd.grasshoff\@hu-berlin.de], orcid: [0000-0002-4450-357X] ), ), keywords: ([digital humanities],[document processing],[optical character recognition],[computational epistemology],[PDAI format],[schema-first OCR],[scholarly infrastructure],[large language models],[grounding evidence],), running-title: [PDAI Document Infrastructure], abstract: [Computational epistemology --- the systematic study of how knowledge claims in historical science can be evaluated computationally --- requires primary sources that are queryable down to the page and sentence. This paper presents PDAI (Portable Document AI), a certified document infrastructure that transforms scanned scholarly editions into structured, queryable corpora with full production audit and learning-enabled quality assurance. The infrastructure has three layers: the PDAI format (a flat, predictable XML directory structure validated against a declared DTD grammar), the production pipeline (a Partitur-governed sequence from ingest through schema-first OCR, pilot-gated batch processing, and a mandatory certify-and-learn stage), and the contextual data stores (corpus file with lemma postings and BM25, famulus file, gazetteer, LWgrammar lexicon, register of individuals) that make a certified page answerable to structured scholarly queries. A reference layer, the Famulus Reference, gives every document, page and segment a stable `fr:` address, a citation drawn only from recorded bibliographic data, and provenance and validation records, kept in one DuckLake and served by one MCP server. The paper describes the nine structural categories of the PDAI page grammar and shows through five case studies how they capture the genuine typographic diversity of early modern scholarly editions --- epistolary apparatus, astronomical ephemerides, cuneiform transliteration, manuscript catalogue entries, and philosophical correspondence. A page enters the corpus only after the audit gate: every defect is attributed to one of four root-cause layers, registered as a versioned learning case, and closed through an A/B-gated instruction improvement loop. The result is a certified chain from PDF to the ai-viewer endpoint where pages serve as Grounding Evidence for computational epistemological inquiry --- citable, provenance-tracked, and regression-anchored. ], abstract-title: "Abstract", font: ("Source Sans 3",), sectionnumbering: "1.1", toc: true, toc_title: [Table of contents], toc_depth: 3, doc, ) = Introduction: Documents as Grounding Evidence The history of science is a record of claims. Kepler claims that planetary motion obeys an area law; Euler derives the shape of tidal forcing from a Newtonian potential; the Babylonian astronomical diaries record Jupiter's daily position in degrees and seconds. These claims are embedded in primary sources --- letters, treatises, observation records --- most of which reached the digital age as scanned PDFs: visually faithful, computationally opaque. #arxivai.sidebar(title: [Grounding Evidence])[ *Grounding Evidence* is the direct textual or observational basis on which a historical knowledge claim rests — as distinct from secondary literature that interprets or summarises it. A page of Kepler's correspondence is Grounding Evidence for claims about his reasoning; a monograph about Kepler is Sekundärevidenz. The distinction is operational: PDAI pages are structured to serve as Grounding Evidence, which means they must be citable at the sentence level, provenance-tracked, and answerable to structured queries without paraphrase. ] A scanned PDF preserves what a document looks like. It cannot answer the question: #emph[on how many pages of the Kepler correspondence does Kepler mention the law of refraction?] To answer this question computationally, a system needs not images but structured transcriptions --- transcriptions that record not just characters but the elements of scholarly apparatus: headers, sections, paragraphs, marginal notes, footnotes, figures, tables, mathematical expressions, and the epistolary skeleton of a letter. It needs those transcriptions validated against a declared grammar so that queries can be written against a stable schema. And it needs provenance: which model produced this transcription, under which instruction, on which date. The Portable Document AI (PDAI) infrastructure addresses these requirements. This paper describes its format, its production pipeline, and the certify-and-learn system that makes a PDAI page a trustworthy unit of Grounding Evidence. Three companion papers carry related material: the orchestration architecture behind the query pipeline is presented in @grasshoff2026aipartitur\; the linguistic index layer (lemma tables, dependency parses, DuckDB query engine) in @grasshoff2026aistanza\; and the research platform that reads PDAI pages as evidence for scholarly operations in @grasshoff2026aierasmus. The reference layer is documented with its package (`pdai/famulus-reference/README.md`); its addresses are consumed by the justification library famulus-proof (`development/famulus-proof`). = The PDAI Format == The problem with PDF The PDF standard @pdf2020 solves a real problem: document appearance is preserved across platforms. This made it the canonical format for digitised historical collections. But the solution creates a different problem for computational scholarship. A scanned PDF encodes position on a page, not structure. A footnote marker in the text and its referent at the page foot share no programmatic connection; a column in a table of astronomical data has no type. Even PDFs with embedded text layers from conventional OCR contain errors, lose structure, and cannot distinguish a marginal annotation from the main text. The consequence is that the scholarly question --- what does this document say about this topic --- can be asked of a PDF only through full-text string search, which neither understands structure nor tolerates the orthographic variation of historical texts. The TEI guidelines @tei2023 offer rich semantic encoding, but at a cost: TEI markup is written by human editors and is not automatically produced from scans. For large historical corpora --- tens of thousands of pages across dozens of editions --- TEI encoding is not feasible. What is needed is automatic production of structured XML from page images, at sufficient quality to serve as Grounding Evidence. == The PDAI directory structure A PDAI document is a directory on the filesystem. The directory structure separates concerns while maintaining clear relationships between layers: ``` /Volumes/PDAI-1/pdai-collections// status.json readiness manifest (pdai-complete) _corpus.duckdb sentence corpus, lemma table, BM25 (corpus/2) _famulus.duckdb derived tables: FQL, evidence, sentence records, dossier, famulus reference, letters, register gazetteer_*.parquet person, place and domain gazetteer _lake/ the collection's Parquet files in the DuckLake / .pdf original source PDF biblio.xml bibliographic identity (biblio.dtd) pdai-config.json harvest and processing record pages/ page_NNN/ page_NNN.png page image at production resolution page_NNN.txt raw OCR text (fastimage, used as signal only) page_NNN.xml structured PDAI XML (the primary output) segments/ letters/ materialised letter records structure.xml document-level assembly ~/ai-mac-studio/pdai/collections//quality/ _defects.json structural audit results / tag_hierarchy_protocol.jsonl structural fingerprints for regression learning_fault_dossier.json fault cases for the learning loop audit_report_.pdf LettreAI Typst certification report ``` The separation is principled. Source images occupy `pages/`\; OCR outputs are co-located with the page. `segments/` holds assembled records that span multiple pages. `structure.xml` is the document-level view. The audit and learning layer `quality/` lives with the collection's code, not with its data: it accumulates without touching production files. This structure embodies two design principles: separation of concerns (no processing step overwrites source data) and accumulative growth (the quality folder grows as auditing deepens, without invalidating transcriptions). == The canonical page DTD Every PDAI page is validated against `page.dtd` v4.0 (or a collection wrapper of it), maintained at `pdai/pipeline/ai-scholar/src/ai_scholar/schemas/page.dtd`. The DTD is the grammar contract: any query written against the PDAI schema can rely on the elements in #ref(, supplement: [Table]) being present and structurally correct. The page language, together with the collection wrappers described below, covers nine structural categories. Seven are declared by the canonical grammar itself; the epistolary apparatus comes from the letter wrapper `page_letter.dtd`, and the `` element from the wrappers that need it: #figure([ #table( columns: (16.22%, 48.65%, 35.14%), align: (auto,auto,auto,), table.header([Category], [Elements], [Role in scholarly apparatus],), table.hline(), [Page frame], [``, ``, `
`, `