Skip to content

feat(math): preserve mathematical notation across every frontend - #76

Open
erkurtharun wants to merge 14 commits into
firecrawl:mainfrom
erkurtharun:feat/math-support
Open

feat(math): preserve mathematical notation across every frontend#76
erkurtharun wants to merge 14 commits into
firecrawl:mainfrom
erkurtharun:feat/math-support

Conversation

@erkurtharun

Copy link
Copy Markdown

Why

anydoc's own design note says the audience is AI agents and the output is
optimised for semantic quality. Mathematical notation was the one place that
promise broke, and it broke by changing values rather than presentation:

The document says anydoc gave
10⁻³ mol/L 10-3 mol/L
H₂O H2O
E = mc² (MathML, no TeX annotation) E=mc2
as <mfrac><mn>1</mn><mn>3</mn></mfrac> 13
an equation in a slide or an ODF document nothing at all
&beta; &amp;beta;

A lost bold is a lost presentation. A lost superscript is a different number,
and no consumer downstream can tell it was ever an exponent.

What changed

A run's position on the baseline is now part of the model. Style gains
vert_align, read from w:vertAlign (DOCX), style:text-position (ODF), the
baseline percentage (PPTX and PPT), sprmCIss and sprmCHpsPos (DOC), and
\super / \sub / \nosupersub / \up / \dn (RTF). It renders as
<sup> / <sub>, which GFM already carries.

Equations become LaTeX in $…$ / $$…$$. Two converters feed it:

  • shared/omml.rs — Office Math, from DOCX and from the a14:m PowerPoint
    wraps in an mc:AlternateContent. That AlternateContent was skipped whole,
    so not even its fallback text survived.
  • shared/mathml.rs — MathML, for HTML and EPUB, and for the formula
    sub-document ODF references through draw:object. Only some MathML carries
    a TeX annotation; without one the markup used to arrive as bare characters.

Structure is translated; glyphs are not. KaTeX takes Unicode operators and
Greek directly and has metrics for them, so α and are left as the
document wrote them — mapping them to commands would only be a chance to pick
the wrong one. The invisible operators (U+2061–2064) are the exception: they
carry no glyph, so passing them through would put unreadable codepoints in
front of a reader. An unmapped glyph is passed through, never guessed at.

Two escaping defects came out of this and are fixed. A paired $ in
document text would open a math span and hand the rest of the document to the
Markdown parser; so would a bare $ inside a TeX annotation. Both are now
escaped or refused.

Named character references were resolved for about forty names; everything
else fell through as literal text and was then escaped again, so &beta;
reached the reader as &amp;beta; — destroyed rather than merely unresolved.
The full HTML5 table is generated from the WHATWG entities.json. MathML
depends on these more than prose does.

RTF character styles (\cs) were not applied at all, so every property one
carried was lost — bold and italic as much as the script this branch went
after.

Verification

  • 260 unit/integration tests, node bindings 14/14, cargo fmt,
    cargo clippy -- -D warnings, and clippy on wasm32-unknown-unknown.
  • A KaTeX gate. The claim "inside the subset KaTeX implements" was never
    measured. node/katex.test.mjs walks the document model — which carries
    latex and display directly, so no re-parsing stands between what the
    converter produced and what is checked — and renders every equation with
    throwOnError and strict: "error". Verified to fail: emitting an undefined
    control sequence breaks it.
  • Differential run against main over all 72 fixtures: 66 byte-identical,
    6 changed, and all 6 carry mathematics. No panics.
  • Fuzzing. 21,000 byte mutations, then 2,000 mutations of the math XML
    inside the packages. No panics. Rendering everything that survived found 9
    unrenderable equations, all from annotations passed through verbatim — that
    is the defect the annotation guard above fixes.
  • Adversarial corpus: 20,000-deep nesting, a 120,000-cell table, 600,000
    entity references, a self-referencing ODF object, a cyclic RTF style. All
    bounded; the size and cell caps truncate while staying balanced. 20,005
    equations, zero KaTeX failures.
  • Performance: on 20,000-paragraph documents, math-free conversion is
    unchanged (+0.2% DOCX, −7.1% EPUB, −4.6% RTF, byte-identical output).
    Math-heavy DOCX costs +1.0% while emitting twice the content.

Known limits

  • Spreadsheets. A cell's rich text still flattens (10⁻³10-3). The
    loss is at the calamine boundary: <si> runs collapse into Data::String,
    and neither the shared-string table nor per-cell indices are public, so
    keying on anything else would be a guess. Preserving it means reading XLSX
    worksheets directly rather than through calamine — a separate decision.
  • Annotations with a misspelled control sequence still pass through.
    Telling that from a macro would take a command table, and a table would
    reject legitimate LaTeX that is not in it.

Related

PDFs bypass the document model and go through pdf-inspector, where the same
class of defect lives: scripts escape their line and reorder. That half is
firecrawl/pdf-inspector#32610 -3 mol/L10⁻³ mol/L, and
565.0 kg/m + a stray /kg line → 565.0 kg/m³. PDF coverage here only
improves once that PR lands and a release follows
, since this crate pins
pdf-inspector = "0.1.7". The chain was verified locally with a
[patch.crates-io] override, which is not part of this branch.

… math

A dollar pair delimits math for every renderer that supports it, so a
document saying "costs $100 and $80" was serialized unescaped and read
back as an equation spanning the two amounts. Escape a dollar that has a
later dollar in the same run, on the same pairing rule the other inert-when-
lone delimiters already use; a single dollar opens nothing and stays literal,
which leaves the common currency case untouched.
Word carries three things this converter dropped. A `w:vertAlign` run is
super- or subscript, and flattening it changes a value rather than a
presentation: `10^-3` became `10-3`, `H_2 O` became `H2O`. An `m:oMath`
equation vanished entirely, and an `m:oMathPara` took its whole paragraph
with it, because all three namespace filters in the docx frontend admit
only `w:*` and OMML lives in its own namespace.

`Style` gains `vert_align`. It is a value, not a toggle: ECMA-376 s17.7.3
closes the toggle set and `w:vertAlign` is not in it, so it cannot ride the
XOR parity `Toggles` uses, and `on_off` cannot even tell `superscript` from
`subscript` since both are outside the false-set. It resolves through the
ordinary nearest-specification path instead, and the other frontends fill it
too: DrawingML `a:rPr/@baseline` and ODF `style:text-position`.

`Inline::Math` carries LaTeX, rendered between dollars. It is the only inline
whose payload reaches the output unescaped, since `escape_text` escapes every
backslash and would destroy each command, so the producer owns making the
body safe.

`formats::docx::omml` translates the OMML element set. It parses to a small
tree before emitting, because bracing is a question about a node's shape --
`y^{2}` used as another script's base needs braces or LaTeX reads a double
superscript, while `\frac{a}{b}` does not -- and that cannot be decided by
inspecting the emitted string. Property bags are read only through named
lookups and never enumerated: each is optional in the schema, Word omits them
whenever every property takes its default, and an absent bag must resolve
exactly as an empty one does. Defaults follow the spec, including the n-ary
operator defaulting to the integral rather than the sum.

Hostile input is bounded and neutralised. Nesting past 64 levels degrades to
the subtree's text rather than recursing, output is capped per equation
because wrappers multiply through nesting, and text runs are LaTeX-escaped so
a dollar cannot close the span and hand the rest of the document to the
Markdown parser, nor a backslash smuggle in a command. A delimiter separator
is emitted as `\mid` rather than a bare pipe, which would split the table row
the equation sits in.

Emitted commands stay inside the subset KaTeX implements, and a body holding
a dollar takes the longer fence: Markdown math parsers do not honour a
backslash-escaped dollar when scanning for the closing delimiter.
Four bugs, all silent, found by adversarial review of the previous commit and
each reproduced before it was fixed.

A `w:ins` wrapping exactly one element dropped it. `parse_seq` collapses a
one-element sequence to that element, so the `if let Node::Seq(..)` guard on
the revision-mark branch never matched and control fell through to `continue`.
Tracked changes around a single fraction lost the whole equation.

An unmapped n-ary operator became an integral and an unmapped accent became a
hat. Well-formed, KaTeX-valid and a different expression than the document's.
Both now pass the glyph through, which says what the document said; an unmapped
group character draws nothing rather than an underbrace.

`m:begChr`, `m:endChr` and `m:sepChr` are author-supplied and reached the LaTeX
body without escaping, so a delimiter of `$` put a bare dollar in a payload
whose contract says there is none, and one of `\` could open a command. They
take the same escaping as any other text now.

Degradation was silent everywhere: the crate routes recovery through `log`
and this module logged nothing.

EPUB and any HTML input carried the same losses and one more. `<sup>` and
`<sub>` were flattened, so the same content read `10<sup>-3</sup>` from DOCX
and `10-3` from EPUB. MathML was worse than dropped: `<math>` is not a
container tag, so the walker descended into it and emitted the presentation
tree as symbol soup *and* the `<annotation encoding="application/x-tex">`
beside it as visible text, backslashes doubled by the escaper. The annotation
is exact where re-deriving LaTeX from the presentation tree is not, so it is
taken when present; without one the characters are kept and the shape is lost.
resolve_entity knew about forty names. Everything else fell through to the
literal `&name;`, which the Markdown writer then escaped again, so `&beta;`
reached the reader as `&amp;beta;` — the reference destroyed rather than
merely unresolved.

MathML depends on these names more than prose does: `&alpha;`, `&sum;`,
`&InvisibleTimes;` are how most producers spell their operators.

The table is generated from the WHATWG entities.json, restricted to the
names that carry a trailing semicolon, which is every name XML admits.
Sorted for binary search; the format, separator and combining characters
are written as escapes so nothing invisible sits in the source.
The EPUB path only recovered an equation when the producer had embedded a
TeX annotation. Most do not, and without one the markup reached the writer
as its bare characters: a <mfrac> over 1 and 3 read as "13", which is not a
degraded fraction but a different number.

Structure is what gets translated. Glyphs are left alone — KaTeX takes
Unicode operators and Greek directly and has metrics for them, so mapping
them to commands would only be a chance to pick the wrong one. The
invisible operators are the exception: they carry no glyph, so passing them
through would put unreadable codepoints in front of a reader.

Covers the presentation set: scripts (including mmultiscripts), fractions,
radicals, under/over with limits and accents, tables, mfenced, semantics,
and the token elements. Symbols are trimmed and prose is not, so the space
in <mtext>if </mtext> survives as a word boundary.

The escaping and Unicode-to-command tables move to shared/latex.rs, which
both math frontends now resolve against, with the same rule as before: an
unmapped glyph is passed through, never guessed at.
\super, \sub and \nosupersub were not in the dispatcher, so RTF alone kept
flattening what the other frontends now preserve: 10\super -3 came out as
10-3, the exact shape of the original report.

\upN and \dnN carry an offset in half-points rather than a toggle, so only
0 means the baseline and an absent parameter takes the spec's default of 6.
\plain already resets vert_align through Style::PLAIN.

A paragraph style may carry the property too, so the stylesheet parser
records it in the delta it already builds for bold and italic.
Word 97-2003 never read sprmCIss, and the PPT frontend hardcoded the
baseline, so both flattened what every other frontend now keeps.

DOC gains sprmCIss (0x2A48: 1 superscript, 2 subscript) and sprmCHpsPos
(0x4845), the signed half-point offset Word writes for "raised by" rather
than for the checkbox — the same pair as RTF's \super and \up.

PPT already walked past the TextCFException position field, a signed
percentage of the font size, so reading it is a matter of not skipping it.

Both are covered by handmade fixtures with the property in real record
bytes, because a unit test over a synthetic grpprl proves the parser and
not the path that reaches it.
Neither frontend reached the equation at all, and the loss was total rather
than partial: a slide read "Energy:  done" and a document "Einstein said
and stopped."

PowerPoint writes an equation as a14:m inside an mc:AlternateContent whose
fallback is a picture of it. The paragraph walker skipped every child
outside the a namespace, so not even the fallback text survived. It now
resolves the AlternateContent and hands a14:m to the OMML converter, which
moves to shared/ because two frontends read it.

ODF keeps a formula in a sub-document of its own, referenced by
draw:object. Following the reference gives MathML, which the MathML
converter already translates; some producers put it inline in the element
instead, so both spellings are read. A reference that resolves to nothing
still degrades to the frame's alternative text.

The TeX-annotation preference moves into the MathML converter so every
caller gets it: what the author wrote beats anything derived from the
layout, while ODF's StarMath annotation is not LaTeX and is ignored.
The math frontends say they emit LaTeX inside the subset KaTeX implements.
Nothing measured that, so the claim held only as far as the reviewer's eye.

The gate walks the document model rather than the Markdown — the model
carries latex and display directly, so no re-parsing stands between what
the converter produced and what is checked — and renders each equation with
throwOnError. Every fixture named handmade-math must contribute at least
one, because a walk that quietly stops finding anything would otherwise
pass while measuring nothing.

Verified to fail: emitting an undefined control sequence from the MathML
converter breaks it, as it should.
\cs was not in the dispatcher and the stylesheet parser recorded only
paragraph styles, so every property a character style carried was lost —
bold and italic as much as the script this branch went after.

RTF numbers character styles in a space of their own, so \cs15 and \s15 are
different styles and need separate maps; the \sbasedon resolution is the
same either way and moves into a function both use.

A character style applies over the run's own formatting rather than
replacing it, which is what the tri-state delta already does.
Found by fuzzing the math XML inside the fixtures and rendering everything
that survived: 9 of 1631 equations came out unrenderable, all from the same
place. A TeX annotation was passed through verbatim, so a broken one became
broken output while a perfectly good presentation tree sat beside it.

The annotation still wins when it can parse. Three ways it certainly cannot
now yield to the presentation tree instead: unbalanced braces, a trailing
script marker with no argument, and a bare `$` — that last one ended the
math span early and handed the rest of the document to the Markdown parser,
which is the same defect the document-text path was already guarded against
and this path bypassed.

A literal `^` or `~` was spelled `\^{}`, which is a text-mode accent LaTeX
rejects in math mode; it is now written as the text it is.

The KaTeX gate runs in strict mode, because a construct KaTeX renders while
warning about it is still not LaTeX.

What remains unrenderable is an annotation whose control sequence the author
misspelled. Telling that from a macro would take a command table, and a
table would reject legitimate LaTeX that is not in it.
Copilot AI lite review requested due to automatic review settings August 9, 2026 15:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@erkurtharun
erkurtharun requested a lite review from Copilot August 12, 2026 08:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@erkurtharun
erkurtharun requested a lite review from Copilot August 12, 2026 08:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@erkurtharun
erkurtharun requested a lite review from Copilot August 16, 2026 10:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@erkurtharun
erkurtharun requested a lite review from Copilot August 16, 2026 10:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

erkurtharun and others added 2 commits August 16, 2026 14:18
`almila-document-converter-service` derives the storage key of every converted
document from `{converter}-{version}-{fingerprint}` (`docs/adr/0010`), so two
builds that answer with the same version mint the same key while producing
different Markdown. After merging upstream v0.1.9 this branch did exactly that:
it reports 0.1.9 and preserves superscripts, subscripts and Office Math that
upstream 0.1.9 drops.

CalVer because the three standard ways to mark a fork all fail a constraint
that consumer enforces or that packaging requires:

  0.1.9+almila.1   PEP 440 local version, but `+` is refused by that service's
                   object-key component rule ([A-Za-z0-9._-]+), which guards a
                   key against traversal and is the wrong thing to loosen
  0.1.9-almila.1   valid semver, not valid PEP 440 -- maturin cannot ship it
  0.1.9.post1      valid PEP 440, not valid semver -- Cargo cannot declare it
  0.2.0            valid everywhere, and collides the day upstream releases it

2026.8.16 is valid semver, valid PEP 440, passes the key rule, and cannot
collide with upstream's 0.x line. `scripts/check-versions.sh` passes on all five
declarations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL9pEBeAREYDRA62h6z1n6

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

29 issues found across 54 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="wasm/src/typescript.rs">

<violation number="1" location="wasm/src/typescript.rs:108">
P2: The TS `Style.vertAlign` field name does not match what `toDocument` actually serializes. In `wasm/src/document.rs` the `Style` struct has no `#[serde(rename_all = ...)]`, so its field is emitted as `vert_align`, not `vertAlign` (unlike `Inline`, which carries camelCase). Consumers reading `inline.style.vertAlign` will get `undefined`. Align the two: either add `#[serde(rename_all = "camelCase")]` to the Style struct and keep `vertAlign` here, or declare `vert_align` in this file.</violation>
</file>

<file name="node/katex.test.mjs">

<violation number="1" location="node/katex.test.mjs:33">
P2: The `walk()` helper never descends into lists or tables, so equations inside them are not collected or rendered. Blocks expose `list.items[].blocks` (not `block.items`) and `table.grid[][].cell.blocks` (not `block.rows`/`row.cells`). As written this test validates only paragraph, heading, and block-quote math, undercutting the "every equation the corpus produces" claim; the handmade-math `counts.has` guard still passes whenever a fixture has any paragraph-level equation, so table/list math can be missed silently. Traverse `block.list?.items` and `block.table?.grid` (origin slots' `cell.blocks`) to actually cover the corpus.</violation>

<violation number="2" location="node/katex.test.mjs:83">
P3: This assertion is always true and can never fail on its own. The `handmade-math` loop just above already asserts that every fixture whose path contains 'handmade-math' (the docx, pptx, odt, and epub fixtures) is present in `counts`, so `withMath.length` is already guaranteed to be >= 4 when execution reaches here. The magic number also silently misleads a future reader into thinking it independently guards corpus coverage. Either drop the redundant check or replace it with a check that is actually independent of the handmade-math loop (for example asserting that non-handmade fixtures such as the real-world `pres.ppt`/`pres.odp` also carry equations).</violation>
</file>

<file name="src/shared/mathml.rs">

<violation number="1" location="src/shared/mathml.rs:43">
P1: When an equation contains a nested `<semantics>` subexpression, `tex_annotation` returns that subexpression’s annotation and drops the outer terms. Restrict annotation lookup to the top-level presentation semantics node.</violation>

<violation number="2" location="src/shared/mathml.rs:131">
P2: A single large MathML text node or TeX annotation bypasses `MAX_LATEX_BYTES`, producing unbounded LaTeX and extra allocation despite the documented equation limit. Enforce the cap while copying text and annotations before constructing `Inline::Math`.</violation>

<violation number="3" location="src/shared/mathml.rs:226">
P2: MathML `mathvariant` values such as `bold` and `double-struck` currently render with default italic typography. Preserve supported variants with KaTeX commands or Unicode variant glyphs, including inherited `mstyle` values.</violation>

<violation number="4" location="src/shared/mathml.rs:304">
P2: When MathML sets `bevelled="true"`, `fraction` still emits a horizontal `\frac` bar instead of a slash-style fraction. Handle the bevelled form before the normal fraction path.</violation>
</file>

<file name="src/formats/rtf/tables.rs">

<violation number="1" location="src/formats/rtf/tables.rs:230">
P2: When an RTF stylesheet uses `\upN` or `\dnN`, this branch ignores the control, so applying that `\csN` or `\sN` loses the script position. Handle `up` and `dn` here with zero as baseline, matching `text_control`.</violation>
</file>

<file name="src/shared/latex.rs">

<violation number="1" location="src/shared/latex.rs:21">
P2: A single literal text run bypasses `MAX_LATEX_BYTES`, allowing an equation to grow past the advertised 64 KiB bound. Enforce the limit while appending text and structural output, or truncate/fail once the limit is reached.</violation>

<violation number="2" location="src/shared/latex.rs:140">
P2: Unsupported delimiter characters are escaped with text-only constructs that cannot follow `\\left` or `\\right`. Handle arbitrary delimiters without `\\left`/`\\right`, or map only delimiter forms that KaTeX accepts in those positions.</violation>
</file>

<file name="src/shared/html.rs">

<violation number="1" location="src/shared/html.rs:448">
P2: An XHTML `<annotation>` outside MathML is now silently dropped. Scope this metadata case to MathML elements so visible EPUB annotation content still reaches the model.</violation>

<violation number="2" location="src/shared/html.rs:460">
P2: A `display:none` rule on a MathML child is ignored because this conversion consumes the entire subtree before child CSS is evaluated, causing hidden terms to appear in output. Filter hidden MathML descendants before conversion or pass the CSS cascade into the MathML conversion.</violation>
</file>

<file name="src/render/markdown/escape.rs">

<violation number="1" location="src/render/markdown/escape.rs:81">
P2: When dollar text is split across a non-plain run and a following plain run, this branch leaves both dollars unescaped, allowing downstream math parsing to consume ordinary text as an equation. Propagate dollar-aware lookahead across run boundaries or escape dollars in every non-plain run, including superscript and subscript runs.</violation>
</file>

<file name="src/formats/odf/text.rs">

<violation number="1" location="src/formats/odf/text.rs:431">
P2: Formula-only frames in ODP never reach this new branch because `parse_presentation` invokes `walk_frame` only for frames containing `draw:image`. Route `draw:object` frames through `walk_frame` as well, so ODP formulas are preserved.</violation>

<violation number="2" location="src/formats/odf/text.rs:435">
P2: load_formula treats any embedded draw:object whose content.xml contains a <math> element as a formula and returns it early, bypassing the image/alt representation for that frame. Non-formula objects with incidental MathML now change output, and the whole embedded document is parsed solely to search for math. Restrict the emission to objects that are actually formula objects (e.g. check the object type/mimetype) before pushing math and returning early.</violation>

<violation number="3" location="src/formats/odf/text.rs:471">
P2: Every embedded `draw:object` now loads and parses `content.xml`, including non-formula objects such as embedded documents or spreadsheets. A large unrelated object can therefore hit the package resource limit and fail conversion; identify formula objects before loading their sub-document, or otherwise keep non-formula object failures recoverable.</violation>
</file>

<file name="src/formats/ppt/mod.rs">

<violation number="1" location="src/formats/ppt/mod.rs:555">
P3: Master-level vertical alignment defaults are dropped. `parse_cf_exception` now extracts `vert_align` from TxMasterStyleAtom character exceptions, but `parse_master_style` discards it when constructing `MasterLevel`, and the per-char style here falls back to a hardcoded `VertAlign::Baseline` instead of the master default `d` (unlike `bold`/`italic`, which use `.or(d.bold)` / `.or(d.italic)`). Add `vert_align` to `MasterLevel`, populate it in `parse_master_style`, and fall back to `d.vert_align` so a master that defaults a level to superscript/subscript is honored.</violation>
</file>

<file name="src/shared/omml.rs">

<violation number="1" location="src/shared/omml.rs:252">
P3: An explicitly empty `m:sepChr` (a document that removes the separator between delimiter parts) falls back to the `|` default and renders a `\mid` bar, while an explicitly empty `begChr`/`endChr` correctly renders no glyph. For consistency with the delimiter handling and the source, an empty separator should mean no separator rather than defaulting back to `|`.</violation>

<violation number="2" location="src/shared/omml.rs:326">
P1: When an `m:t` contains multiple characters, a script binds only the final character. Brace multi-character `Node::Run` bases before emitting scripts.</violation>

<violation number="3" location="src/shared/omml.rs:345">
P2: A single large `m:t` bypasses the 64 KiB guard because `push_text` writes the whole run after `emit` checks only the current length. Enforce the cap while escaping runs.</violation>

<violation number="4" location="src/shared/omml.rs:358">
P2: A `fPr type="noBar"` fraction is emitted as `\binom{num}{den}`, which renders wrapped in parentheses, but Word's noBar draws n over k with no bar and no parentheses. The MathML converter emits the matching construct (`<mfrac linethickness="0">`) as `\atop`, which draws no parentheses — so the two converters silently disagree and OMML gains parens the source never had. Emit `\atop` here too for parity and fidelity.</violation>

<violation number="5" location="src/shared/omml.rs:408">
P2: When an unmapped n-ary glyph uses `limLoc="undOvr"`, this emits `⨌\limits`, which KaTeX rejects because the glyph is not an operator. Wrap it in `\mathop{...}` before applying limits.</violation>

<violation number="6" location="src/shared/omml.rs:457">
P2: When `m:groupChrPr/m:chr` contains an unmapped glyph, this branch drops it and renders only the base. Preserve it with `\overset` or `\underset`.</violation>
</file>

<file name="python/anydoc/_anydoc.pyi">

<violation number="1" location="python/anydoc/_anydoc.pyi:102">
P2: The `math` kind was added to the `Inline.kind` literal, but the stub does not declare the new fields the runtime exposes. `python/src/document.rs` (a `get_all` pyclass) now reports `inline.latex`, `inline.display`, and `style.vert_align`, but `_anydoc.pyi` omits all three, so type checkers will reject valid accesses to these attributes. Add the missing fields to the `Inline` and `Style` classes in the stub.</violation>
</file>

<file name="node/index.js">

<violation number="1" location="node/index.js:80">
P3: The version check now compares against '2026.8.16' but the thrown error message still says `expected 0.1.9 but got ${bindingPackageVersion}`. A user hitting the version-mismatch path will be told the expected version is 0.1.9, which is now wrong and points them at a stale version. Update the message in all 27 occurrences (including the WASI one at line 652) to reference 2026.8.16.</violation>
</file>

<file name="src/formats/rtf/mod.rs">

<violation number="1" location="src/formats/rtf/mod.rs:582">
P2: The `\cs` handler sets `style_base` to the character style's emphasis, but `style_base` is the paragraph-style base that `rebase_emphasis` subtracts from every run of an outline/heading paragraph. A character style carrying bold/italic then strips that emphasis from all heading runs, including runs not using the character style. Character styles carry no paragraph properties, so they should not update `style_base`. Drop the assignment.</violation>
</file>

<file name="src/formats/pptx/mod.rs">

<violation number="1" location="src/formats/pptx/mod.rs:39">
P2: Adding A14 to the module-global SUPPORTED_NS changes AlternateContent branch selection for every a14 choice, not just math. A non-math a14 `mc:Choice` now beats its Fallback, but push_para_inlines only emits `a14:m`/`m:oMath`; other a14 children are dropped, so that branch renders empty. Scope the supported set so only math-marked choices are preferred over their Fallback, or handle the fallback when the selected choice has no math content.</violation>
</file>

<file name="src/formats/docx/styles.rs">

<violation number="1" location="src/formats/docx/styles.rs:41">
P2: A named character style that sets `w:vertAlign` (e.g. Word's built-in 'Superscript' style) is not honored: `run_toggles` only XORs bold/italic/strike parity and never reads `w:vertAlign`, and `Toggles::apply_over` passes `base.vert_align` through unchanged. Only `docDefaults` and direct run `w:rPr` produce superscript/subscript in DOCX. Since this PR's goal is baseline fidelity, extend the style-chain resolution to apply a character style's `vertAlign` (nearest specification wins) so the 'Superscript'/'Subscript' character styles render correctly.</violation>
</file>

<file name="src/render/markdown/inline.rs">

<violation number="1" location="src/render/markdown/inline.rs:125">
P2: When a Math run lands in a table cell, its LaTeX is written raw and a `|` in the body (e.g. {x | x > 0}, norms, or matrix rows) is emitted unescaped. GFM splits cells on `|`, so the row gains spurious columns and the whole table is corrupted. `escape_text` explicitly escapes `|` under `InlineContext::TableCell`, but `Norm::Math` bypasses escaping entirely (chosen so backslashes survive), leaving no path that protects cell boundaries. A `|` should only be a delimiter at the cell level, so the Math payload needs table-context handling (at minimum detecting a raw `|` and refusing/escaping the cell) rather than being written verbatim.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/shared/mathml.rs
/// but only while it can still parse, because the presentation tree beside it
/// is a better answer than LaTeX that renders as an error message.
fn tex_annotation(math: &Element) -> Option<String> {
let text = math

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an equation contains a nested <semantics> subexpression, tex_annotation returns that subexpression’s annotation and drops the outer terms. Restrict annotation lookup to the top-level presentation semantics node.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/shared/mathml.rs, line 43:

<comment>When an equation contains a nested `<semantics>` subexpression, `tex_annotation` returns that subexpression’s annotation and drops the outer terms. Restrict annotation lookup to the top-level presentation semantics node.</comment>

<file context>
@@ -0,0 +1,631 @@
+/// but only while it can still parse, because the presentation tree beside it
+/// is a better answer than LaTeX that renders as an error message.
+fn tex_annotation(math: &Element) -> Option<String> {
+    let text = math
+        .descendant_elems()
+        .filter(|e| e.local == "annotation")
</file context>
Fix with cubic

Comment thread src/shared/omml.rs
/// is an error) or when it is a sequence the script would otherwise bind only
/// the last atom of. A fraction, radical or delimiter is a single atom already.
fn base_needs_braces(node: &Node) -> bool {
match node {

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an m:t contains multiple characters, a script binds only the final character. Brace multi-character Node::Run bases before emitting scripts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/shared/omml.rs, line 326:

<comment>When an `m:t` contains multiple characters, a script binds only the final character. Brace multi-character `Node::Run` bases before emitting scripts.</comment>

<file context>
@@ -0,0 +1,676 @@
+/// is an error) or when it is a sequence the script would otherwise bind only
+/// the last atom of. A fraction, radical or delimiter is a single atom already.
+fn base_needs_braces(node: &Node) -> bool {
+    match node {
+        Node::Seq(parts) => parts.len() != 1,
+        Node::Script { .. } | Node::Nary { .. } | Node::Limit { .. } => true,
</file context>
Fix with cubic

Comment thread wasm/src/typescript.rs
strike: boolean
code: boolean
/** `baseline`, `superscript` or `subscript`. */
vertAlign: string

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The TS Style.vertAlign field name does not match what toDocument actually serializes. In wasm/src/document.rs the Style struct has no #[serde(rename_all = ...)], so its field is emitted as vert_align, not vertAlign (unlike Inline, which carries camelCase). Consumers reading inline.style.vertAlign will get undefined. Align the two: either add #[serde(rename_all = "camelCase")] to the Style struct and keep vertAlign here, or declare vert_align in this file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At wasm/src/typescript.rs, line 108:

<comment>The TS `Style.vertAlign` field name does not match what `toDocument` actually serializes. In `wasm/src/document.rs` the `Style` struct has no `#[serde(rename_all = ...)]`, so its field is emitted as `vert_align`, not `vertAlign` (unlike `Inline`, which carries camelCase). Consumers reading `inline.style.vertAlign` will get `undefined`. Align the two: either add `#[serde(rename_all = "camelCase")]` to the Style struct and keep `vertAlign` here, or declare `vert_align` in this file.</comment>

<file context>
@@ -99,6 +104,8 @@ export interface Style {
   strike: boolean
   code: boolean
+  /** `baseline`, `superscript` or `subscript`. */
+  vertAlign: string
 }
 
</file context>
Fix with cubic

Comment thread node/katex.test.mjs
collect(block.content, out)
walk(block.blocks, out)
for (const item of block.items ?? []) walk(item.blocks, out)
for (const row of block.rows ?? []) {

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The walk() helper never descends into lists or tables, so equations inside them are not collected or rendered. Blocks expose list.items[].blocks (not block.items) and table.grid[][].cell.blocks (not block.rows/row.cells). As written this test validates only paragraph, heading, and block-quote math, undercutting the "every equation the corpus produces" claim; the handmade-math counts.has guard still passes whenever a fixture has any paragraph-level equation, so table/list math can be missed silently. Traverse block.list?.items and block.table?.grid (origin slots' cell.blocks) to actually cover the corpus.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/katex.test.mjs, line 33:

<comment>The `walk()` helper never descends into lists or tables, so equations inside them are not collected or rendered. Blocks expose `list.items[].blocks` (not `block.items`) and `table.grid[][].cell.blocks` (not `block.rows`/`row.cells`). As written this test validates only paragraph, heading, and block-quote math, undercutting the "every equation the corpus produces" claim; the handmade-math `counts.has` guard still passes whenever a fixture has any paragraph-level equation, so table/list math can be missed silently. Traverse `block.list?.items` and `block.table?.grid` (origin slots' `cell.blocks`) to actually cover the corpus.</comment>

<file context>
@@ -0,0 +1,84 @@
+    collect(block.content, out)
+    walk(block.blocks, out)
+    for (const item of block.items ?? []) walk(item.blocks, out)
+    for (const row of block.rows ?? []) {
+      for (const cell of row.cells ?? []) walk(cell.blocks, out)
+    }
</file context>
Fix with cubic

Comment thread src/shared/mathml.rs
}
}

fn fraction(elem: &Element, out: &mut String, depth: usize) {

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When MathML sets bevelled="true", fraction still emits a horizontal \frac bar instead of a slash-style fraction. Handle the bevelled form before the normal fraction path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/shared/mathml.rs, line 304:

<comment>When MathML sets `bevelled="true"`, `fraction` still emits a horizontal `\frac` bar instead of a slash-style fraction. Handle the bevelled form before the normal fraction path.</comment>

<file context>
@@ -0,0 +1,631 @@
+    }
+}
+
+fn fraction(elem: &Element, out: &mut String, depth: usize) {
+    // A zero rule is a choose-style stack, which `\frac` would draw a bar under.
+    if elem.attr_any("linethickness").is_some_and(is_zero_length) {
</file context>
Fix with cubic

// Markdown math parsers ignore `\$` when scanning for the close,
// so a body holding a dollar needs the longer fence.
let fence = if *display || latex.contains('$') { "$$" } else { "$" };
let _ = write!(out, "{fence}{latex}{fence}");

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a Math run lands in a table cell, its LaTeX is written raw and a | in the body (e.g. {x | x > 0}, norms, or matrix rows) is emitted unescaped. GFM splits cells on |, so the row gains spurious columns and the whole table is corrupted. escape_text explicitly escapes | under InlineContext::TableCell, but Norm::Math bypasses escaping entirely (chosen so backslashes survive), leaving no path that protects cell boundaries. A | should only be a delimiter at the cell level, so the Math payload needs table-context handling (at minimum detecting a raw | and refusing/escaping the cell) rather than being written verbatim.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/render/markdown/inline.rs, line 125:

<comment>When a Math run lands in a table cell, its LaTeX is written raw and a `|` in the body (e.g. {x | x > 0}, norms, or matrix rows) is emitted unescaped. GFM splits cells on `|`, so the row gains spurious columns and the whole table is corrupted. `escape_text` explicitly escapes `|` under `InlineContext::TableCell`, but `Norm::Math` bypasses escaping entirely (chosen so backslashes survive), leaving no path that protects cell boundaries. A `|` should only be a delimiter at the cell level, so the Math payload needs table-context handling (at minimum detecting a raw `|` and refusing/escaping the cell) rather than being written verbatim.</comment>

<file context>
@@ -109,6 +116,14 @@ fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, r
+                // Markdown math parsers ignore `\$` when scanning for the close,
+                // so a body holding a dollar needs the longer fence.
+                let fence = if *display || latex.contains('$') { "$$" } else { "$" };
+                let _ = write!(out, "{fence}{latex}{fence}");
+            }
             Norm::LineBreak => match ctx {
</file context>
Fix with cubic

Comment thread src/formats/ppt/mod.rs
italic: char_run.and_then(|r| r.italic).or(d.italic).unwrap_or(false),
strike: false,
code: false,
vert_align: char_run.and_then(|r| r.vert_align).unwrap_or(VertAlign::Baseline),

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Master-level vertical alignment defaults are dropped. parse_cf_exception now extracts vert_align from TxMasterStyleAtom character exceptions, but parse_master_style discards it when constructing MasterLevel, and the per-char style here falls back to a hardcoded VertAlign::Baseline instead of the master default d (unlike bold/italic, which use .or(d.bold) / .or(d.italic)). Add vert_align to MasterLevel, populate it in parse_master_style, and fall back to d.vert_align so a master that defaults a level to superscript/subscript is honored.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/ppt/mod.rs, line 555:

<comment>Master-level vertical alignment defaults are dropped. `parse_cf_exception` now extracts `vert_align` from TxMasterStyleAtom character exceptions, but `parse_master_style` discards it when constructing `MasterLevel`, and the per-char style here falls back to a hardcoded `VertAlign::Baseline` instead of the master default `d` (unlike `bold`/`italic`, which use `.or(d.bold)` / `.or(d.italic)`). Add `vert_align` to `MasterLevel`, populate it in `parse_master_style`, and fall back to `d.vert_align` so a master that defaults a level to superscript/subscript is honored.</comment>

<file context>
@@ -552,6 +552,7 @@ impl Extractor {
                 italic: char_run.and_then(|r| r.italic).or(d.italic).unwrap_or(false),
                 strike: false,
                 code: false,
+                vert_align: char_run.and_then(|r| r.vert_align).unwrap_or(VertAlign::Baseline),
             };
             if c == '\r' {
</file context>
Fix with cubic

Comment thread node/katex.test.mjs
if (!path.includes('handmade-math')) continue
assert.ok(counts.has(path), `no equation reached the document model from ${path}`)
}
assert.ok(withMath.length >= 4, `only ${withMath.length} fixtures carried equations`)

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This assertion is always true and can never fail on its own. The handmade-math loop just above already asserts that every fixture whose path contains 'handmade-math' (the docx, pptx, odt, and epub fixtures) is present in counts, so withMath.length is already guaranteed to be >= 4 when execution reaches here. The magic number also silently misleads a future reader into thinking it independently guards corpus coverage. Either drop the redundant check or replace it with a check that is actually independent of the handmade-math loop (for example asserting that non-handmade fixtures such as the real-world pres.ppt/pres.odp also carry equations).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/katex.test.mjs, line 83:

<comment>This assertion is always true and can never fail on its own. The `handmade-math` loop just above already asserts that every fixture whose path contains 'handmade-math' (the docx, pptx, odt, and epub fixtures) is present in `counts`, so `withMath.length` is already guaranteed to be >= 4 when execution reaches here. The magic number also silently misleads a future reader into thinking it independently guards corpus coverage. Either drop the redundant check or replace it with a check that is actually independent of the handmade-math loop (for example asserting that non-handmade fixtures such as the real-world `pres.ppt`/`pres.odp` also carry equations).</comment>

<file context>
@@ -0,0 +1,84 @@
+    if (!path.includes('handmade-math')) continue
+    assert.ok(counts.has(path), `no equation reached the document model from ${path}`)
+  }
+  assert.ok(withMath.length >= 4, `only ${withMath.length} fixtures carried equations`)
+})
</file context>
Fix with cubic

Comment thread node/index.js Outdated
const binding = require('@firecrawl/anydoc-android-arm64')
const bindingPackageVersion = require('@firecrawl/anydoc-android-arm64/package.json').version
if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
if (bindingPackageVersion !== '2026.8.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The version check now compares against '2026.8.16' but the thrown error message still says expected 0.1.9 but got ${bindingPackageVersion}. A user hitting the version-mismatch path will be told the expected version is 0.1.9, which is now wrong and points them at a stale version. Update the message in all 27 occurrences (including the WASI one at line 652) to reference 2026.8.16.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/index.js, line 80:

<comment>The version check now compares against '2026.8.16' but the thrown error message still says `expected 0.1.9 but got ${bindingPackageVersion}`. A user hitting the version-mismatch path will be told the expected version is 0.1.9, which is now wrong and points them at a stale version. Update the message in all 27 occurrences (including the WASI one at line 652) to reference 2026.8.16.</comment>

<file context>
@@ -77,7 +77,7 @@ function requireNative() {
         const binding = require('@firecrawl/anydoc-android-arm64')
         const bindingPackageVersion = require('@firecrawl/anydoc-android-arm64/package.json').version
-        if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
+        if (bindingPackageVersion !== '2026.8.16' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
           throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
         }
</file context>
Fix with cubic

Comment thread src/shared/omml.rs
Some(Node::Delim {
open: chr("begChr", '('),
close: chr("endChr", ')'),
sep: pr_val(elem, "dPr", "sepChr").and_then(first_char).unwrap_or('|'),

@cubic-dev-ai cubic-dev-ai Bot Aug 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: An explicitly empty m:sepChr (a document that removes the separator between delimiter parts) falls back to the | default and renders a \mid bar, while an explicitly empty begChr/endChr correctly renders no glyph. For consistency with the delimiter handling and the source, an empty separator should mean no separator rather than defaulting back to |.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/shared/omml.rs, line 252:

<comment>An explicitly empty `m:sepChr` (a document that removes the separator between delimiter parts) falls back to the `|` default and renders a `\mid` bar, while an explicitly empty `begChr`/`endChr` correctly renders no glyph. For consistency with the delimiter handling and the source, an empty separator should mean no separator rather than defaulting back to `|`.</comment>

<file context>
@@ -0,0 +1,676 @@
+            Some(Node::Delim {
+                open: chr("begChr", '('),
+                close: chr("endChr", ')'),
+                sep: pr_val(elem, "dPr", "sepChr").and_then(first_char).unwrap_or('|'),
+                parts: elem.find_all(ns::M, "e").map(|e| parse_seq(e, d)).collect(),
+            })
</file context>
Fix with cubic

`to_markdown_bytes` concatenates a PDF into one string and reports the pages it
could not read through `log::warn!`, so a 90-page document with 3 scanned pages
converts to 87 pages of confident, non-empty Markdown. Nothing downstream can
tell that from a complete conversion. A caller that can OCR the remainder needs
to know which pages to send; a caller that cannot needs to know the output is
short.

`pdf_pages` returns `PdfPage { index, markdown, needs_ocr, ocr_reason }` per
page, over `pdf_inspector::extract_pages_markdown_mem` -- the API pdf-inspector
documents for exactly this, "so callers can mix direct extraction (for simple
text pages) with GPU OCR (for complex/scanned pages)".

`to_markdown` is deliberately unchanged. The two verdicts DISAGREE, and the
disagreement is measured on this crate's own `tests/fixtures/pdf/text.pdf`:
`process_pdf_mem`, which `to_markdown` reads, flags page 2, whose text layer in
fact yields "i Endnote body text." and which `extract_pages_markdown_mem`
correctly passes. Routing on the document-level flag would send text pages to an
OCR engine, so `to_markdown` keeps its own semantics and the module docstring
records which API is for routing. `tests/pdf_pages.rs` pins the disagreement so
a pdf-inspector upgrade that resolves it says so rather than leaving it to be
rediscovered.

Versioned 2026.8.18 because a consumer derives its storage key from the version
(`almila-document-converter-service` `docs/adr/0010`): a build with a new API
must not mint the previous build's key. All five declarations agree.

cargo fmt --all --check, cargo clippy --workspace --all-targets --all-features
-D warnings, cargo test --locked (273), and python -m unittest (11) pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011XWEecjRapkLVUV99gwuE4

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/lib.rs">

<violation number="1" location="src/lib.rs:145">
P3: `to_markdown_bytes` does not refuse a document whose pages did not all extract — it logs a warning and returns the extractable Markdown (only refusing when no text at all remains). The new docstring's "so it refuses one" is inaccurate and contradicts the module doc and the Python/.pyi docs in this same PR, which say it "logs and degrades." Reword to match the actual behavior ("degrades with a log").</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/lib.rs
/// page's text layer can be trusted.
///
/// [`to_markdown_bytes`] returns one string and cannot express a document
/// whose pages did not all extract, so it refuses one. This returns the pages

@cubic-dev-ai cubic-dev-ai Bot Aug 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: to_markdown_bytes does not refuse a document whose pages did not all extract — it logs a warning and returns the extractable Markdown (only refusing when no text at all remains). The new docstring's "so it refuses one" is inaccurate and contradicts the module doc and the Python/.pyi docs in this same PR, which say it "logs and degrades." Reword to match the actual behavior ("degrades with a log").

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib.rs, line 145:

<comment>`to_markdown_bytes` does not refuse a document whose pages did not all extract — it logs a warning and returns the extractable Markdown (only refusing when no text at all remains). The new docstring's "so it refuses one" is inaccurate and contradicts the module doc and the Python/.pyi docs in this same PR, which say it "logs and degrades." Reword to match the actual behavior ("degrades with a log").</comment>

<file context>
@@ -137,6 +138,18 @@ pub fn to_document(
+/// page's text layer can be trusted.
+///
+/// [`to_markdown_bytes`] returns one string and cannot express a document
+/// whose pages did not all extract, so it refuses one. This returns the pages
+/// that did extract alongside the ones that did not, which is what a caller
+/// able to OCR the remainder needs. The format is not detected and not passed:
</file context>
Suggested change
/// whose pages did not all extract, so it refuses one. This returns the pages
/// whose pages did not all extract, so it degrades with a log. This returns the pages
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants