Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ exclude = ["fuzz"]

[package]
name = "anydoc"
version = "0.1.9"
version = "2026.8.18"
edition = "2024"
# Edition 2024 needs 1.85; zip and calamine both raise it to 1.88.
rust-version = "1.88"
Expand Down
9 changes: 8 additions & 1 deletion node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ export interface Inline {
anchor?: string
/** noteRef: the id of the note in `Document.notes`. */
noteId?: string
/** math: the expression as LaTeX, without delimiters. */
latex?: string
/** math: true for an equation that stands on its own line. */
display?: boolean
}

export declare const enum InlineKind {
Expand All @@ -177,7 +181,8 @@ export declare const enum InlineKind {
/** Zero-width marker for an internal link target at this position. */
anchor = 'anchor',
noteRef = 'noteRef',
lineBreak = 'lineBreak'
lineBreak = 'lineBreak',
math = 'math'
}

export interface LinkTarget {
Expand Down Expand Up @@ -241,6 +246,8 @@ export interface Style {
italic: boolean
strike: boolean
code: boolean
/** `baseline`, `superscript` or `subscript`. */
vertAlign: string
}

/**
Expand Down
54 changes: 27 additions & 27 deletions node/index.js

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions node/katex.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// The math frontends claim to emit LaTeX inside the subset KaTeX implements.
// Nothing measured that claim, so this renders every equation the fixture
// corpus produces and fails on the first one KaTeX will not accept. Strict
// mode is on: a construct KaTeX renders while warning is still not LaTeX.
import assert from 'node:assert/strict'
import { readFile, readdir } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { test } from 'node:test'

import katex from 'katex'

import { toDocument } from './index.js'

const FIXTURES = fileURLToPath(new URL('../tests/fixtures', import.meta.url))

// PDFs bypass the document model, CSV carries no styling, and the malformed
// and abuse corpora exist to fail rather than to convert.
const SKIP_DIRS = new Set(['pdf', 'csv', 'malformed', 'abuse'])

function collect(inlines, out) {
for (const inline of inlines ?? []) {
if (inline.kind === 'math') out.push(inline)
collect(inline.content, out)
}
}

function walk(blocks, out) {
for (const block of blocks ?? []) {
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

for (const cell of row.cells ?? []) walk(cell.blocks, out)
}
}
}

async function equationsIn(path) {
const document = await toDocument(await readFile(path))
const found = []
walk(document.blocks, found)
for (const note of document.notes ?? []) walk(note.blocks, found)
return found
}

async function fixturePaths() {
const paths = []
for (const dir of await readdir(FIXTURES, { withFileTypes: true })) {
if (!dir.isDirectory() || SKIP_DIRS.has(dir.name)) continue
for (const name of await readdir(join(FIXTURES, dir.name))) {
if (extname(name)) paths.push(join(FIXTURES, dir.name, name))
}
}
return paths.sort()
}

test('every equation the corpus produces renders in KaTeX', async () => {
const counts = new Map()
for (const path of await fixturePaths()) {
let equations
try {
equations = await equationsIn(path)
} catch {
continue // Unconvertible fixtures are another test's subject.
}
for (const { latex, display } of equations) {
assert.doesNotThrow(
() => katex.renderToString(latex, { throwOnError: true, strict: 'error', displayMode: display }),
`${path}: ${latex}`,
)
counts.set(path, (counts.get(path) ?? 0) + 1)
}
}

// A walk that quietly stops finding anything would otherwise pass while
// measuring nothing, so each format that carries equations must contribute.
const withMath = [...counts.keys()]
for (const path of await fixturePaths()) {
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

})
30 changes: 29 additions & 1 deletion node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/anydoc",
"version": "0.1.9",
"version": "2026.8.18",
"description": "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown",
"license": "MIT",
"homepage": "https://github.com/firecrawl/anydoc#readme",
Expand Down Expand Up @@ -59,6 +59,7 @@
"version": "napi version"
},
"devDependencies": {
"@napi-rs/cli": "^3.8.2"
"@napi-rs/cli": "^3.8.2",
"katex": "^0.18.3"
}
}
22 changes: 21 additions & 1 deletion node/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ pub enum InlineKind {
anchor,
noteRef,
lineBreak,
math,
}

#[napi(object)]
Expand All @@ -121,6 +122,10 @@ pub struct Inline {
pub anchor: Option<String>,
/// noteRef: the id of the note in `Document.notes`.
pub note_id: Option<String>,
/// math: the expression as LaTeX, without delimiters.
pub latex: Option<String>,
/// math: true for an equation that stands on its own line.
pub display: Option<bool>,
}

impl Inline {
Expand All @@ -135,6 +140,8 @@ impl Inline {
source: None,
anchor: None,
note_id: None,
latex: None,
display: None,
}
}
}
Expand Down Expand Up @@ -163,6 +170,11 @@ impl From<model::Inline> for Inline {
model::Inline::NoteRef(id) => {
Inline { note_id: Some(id), ..Inline::of(InlineKind::noteRef) }
}
model::Inline::Math { latex, display } => Inline {
latex: Some(latex),
display: Some(display),
..Inline::of(InlineKind::math)
},
model::Inline::LineBreak => Inline::of(InlineKind::lineBreak),
}
}
Expand All @@ -175,11 +187,19 @@ pub struct Style {
pub italic: bool,
pub strike: bool,
pub code: bool,
/// `baseline`, `superscript` or `subscript`.
pub vert_align: String,
}

impl From<model::Style> for Style {
fn from(style: model::Style) -> Self {
Style { bold: style.bold, italic: style.italic, strike: style.strike, code: style.code }
Style {
bold: style.bold,
italic: style.italic,
strike: style.strike,
code: style.code,
vert_align: style.vert_align.as_str().into(),
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# dynamic version), so bump it together with the workspace release version.
[package]
name = "anydoc-python"
version = "0.1.9"
version = "2026.8.18"
edition = "2024"
description = "Python bindings for anydoc"
license = "MIT"
Expand Down
4 changes: 4 additions & 0 deletions python/anydoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
MalformedError,
MissingPartError,
Note,
PdfPage,
ResourceLimitError,
Style,
Table,
UnsupportedError,
format_from_bytes,
format_from_extension,
format_from_path,
pdf_pages,
to_document,
to_markdown,
to_markdown_bytes,
Expand Down Expand Up @@ -54,13 +56,15 @@
"MalformedError",
"MissingPartError",
"Note",
"PdfPage",
"ResourceLimitError",
"Style",
"Table",
"UnsupportedError",
"format_from_bytes",
"format_from_extension",
"format_from_path",
"pdf_pages",
"to_document",
"to_markdown",
"to_markdown_bytes",
Expand Down
30 changes: 29 additions & 1 deletion python/anydoc/_anydoc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,34 @@ def to_document(data: bytes | bytearray, format: Format | None = None) -> Docume
Unsupported for `pdf`: PDF conversion produces Markdown directly and has
no document-model form; use `to_markdown_bytes`."""

def pdf_pages(data: bytes | bytearray) -> list[PdfPage]:
"""Extract a PDF page by page, keeping the per-page verdict on whether
that page's text layer can be trusted.

`to_markdown_bytes` returns one string and cannot say that some pages did
not extract; it logs and degrades. This returns the verdict, which is what
a caller able to OCR the remainder needs. Route OCR by `needs_ocr` here
and never by a document-level flag: the two disagree, and this one is the
API documented for routing.

The per-page Markdown is flatter than `to_markdown_bytes`, which sees
structure across a page break that a page on its own cannot.

PDFs only. Anything else raises `MalformedError`."""

@final
class PdfPage:
index: int
"""0-indexed page number, in document order."""
markdown: str
"""Markdown extracted from this page's text layer, empty when the text
layer answered for nothing."""
needs_ocr: bool
"""True when the text layer cannot be trusted here: no text at all,
GID-encoded fonts, broken encodings, or garbage output."""
ocr_reason: str | None
"""Machine-readable reason for `needs_ocr`, where the cause is known."""

@final
class Document:
blocks: list[Block]
Expand Down Expand Up @@ -99,7 +127,7 @@ class Block:

@final
class Inline:
kind: Literal["text", "link", "image", "anchor", "note_ref", "line_break"]
kind: Literal["text", "link", "image", "anchor", "note_ref", "math", "line_break"]

@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 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.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/anydoc/_anydoc.pyi, line 102:

<comment>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.</comment>

<file context>
@@ -99,7 +99,7 @@ class Block:
 @final
 class Inline:
-    kind: Literal["text", "link", "image", "anchor", "note_ref", "line_break"]
+    kind: Literal["text", "link", "image", "anchor", "note_ref", "math", "line_break"]
     """`anchor` is a zero-width marker for an internal link target at this
     position."""
</file context>
Fix with cubic

"""`anchor` is a zero-width marker for an internal link target at this
position."""
text: str | None
Expand Down
Loading