-
Notifications
You must be signed in to change notification settings - Fork 981
feat(math): preserve mathematical notation across every frontend #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
72688f9
5bb32e5
e0f0c7d
191b99c
af17a40
2113b48
0c36785
95a8e17
fa0e48f
a70e483
7b8d111
383a1f3
e93a72b
dcb4660
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Large diffs are not rendered by default.
| 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 ?? []) { | ||
| 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`) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| }) | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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] | ||
|
|
@@ -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"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The Prompt for AI agents |
||
| """`anchor` is a zero-width marker for an internal link target at this | ||
| position.""" | ||
| text: str | None | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 exposelist.items[].blocks(notblock.items) andtable.grid[][].cell.blocks(notblock.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-mathcounts.hasguard still passes whenever a fixture has any paragraph-level equation, so table/list math can be missed silently. Traverseblock.list?.itemsandblock.table?.grid(origin slots'cell.blocks) to actually cover the corpus.Prompt for AI agents