Skip to content

ci: validate separated ruled-grid fix - #1

Open
Chengyunlai wants to merge 65 commits into
mainfrom
codex/fix-separated-grid-components
Open

ci: validate separated ruled-grid fix#1
Chengyunlai wants to merge 65 commits into
mainfrom
codex/fix-separated-grid-components

Conversation

@Chengyunlai

Copy link
Copy Markdown
Owner

Build and test the existing separated ruled-grid fix on the fork's supported Python wheel targets.

This PR is used only to run the fork CI against the exact patch commit; the upstream contribution remains run-llama#392.

hexapode and others added 30 commits July 27, 2026 16:52
`render_line_inline` escaped `*`, `_` and `\` via `escape_inline()` before
handing the text to `apply_style()`, whose mono branch then wrapped the
already-escaped text in backticks. CommonMark: "Backslash escapes do not
work in code blocks, code spans, autolinks, or raw HTML" — so the inserted
backslashes are rendered literally by any conforming renderer.

Prose with a monospace run, driven through `parse_from_pages` with
`output_format = Markdown`:

    "Please call the function named" (Helvetica)
    + "get_user_id" (Courier)
    + "before you continue with the setup." (Helvetica)

    before: Please call the function named `get\_user\_id` before ...
    after:  Please call the function named `get_user_id` before ...

    "Install the runtime under the folder" (Helvetica)
    + "C:\Program\bin" (Courier)
    + "and then restart the service daemon." (Helvetica)

    before: Install the runtime under the folder `C:\\Program\\bin` and ...
    after:  Install the runtime under the folder `C:\Program\bin` and ...

Adds `style_body()`, which skips escaping for mono spans, and routes the
three escape-then-style call sites through it: the uniform-line fast path
and the per-group path in `render_line_inline`, and the uniform path in
`render_list_item_text`. The mixed-style fallback at the end of
`render_list_item_text` keeps plain `escape_inline` — it has no single
style to consult.

Only markdown output changes, and only for mono spans that contain `*`,
`_` or `\`; the delta is the removal of backslashes that were never
meaningful in that position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes followups 1 and 2 in TABLE_TEDS_PROGRESS.md. On opendataloader-bench
TEDS goes 0.7128 -> 0.7441, cutting the gap to pdf-inspector from 0.101 to
0.070; overall 0.8757 -> 0.8783. Neutral-to-positive on the ParseBench table
dimension (0.4032 -> 0.4034).

Two-pass last-resort for header + single-data-row tables. try_detect_table_
inferred takes allow_two_row; two_row_second_pass retries only in the index
gaps where the normal pass found no table, so it cannot steal a real table's
header. The gap restriction alone is not sufficient — fully-justified prose
infers clean tracks from its stretched inter-word spaces, so two_row_run_
plausible also requires isolation and header shape. Doc 197: 0.000 -> 0.789,
one doc changed, no collateral.

Soft-wrap row grouping (merge_continuation_rows), applied where ruled and
borderless runs converge. A row folds into its predecessor when it has an
empty first cell, its filled columns are a subset of the predecessor's, and
it carries no value-like cell. Guarded by a first-column fill test: "empty
first cell" only means "wrapped line" when the first column is a label
column, and without it sparse-first-column tables (timetables, size charts)
collapse to a single row. Doc 150: 0.446 -> 0.861.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TKUnWQHGdXrcP9KNSsckd
PDFium routinely emits a whole table row as one text run, which is the
root of the track chicken-and-egg: the detector needs column tracks to
split a merged run, but the merged runs are where the track evidence
lives. Word boxes break the cycle — the gutter is visible in the geometry
before any table hypothesis exists.

split_span_at_gutters splits a run at its internal gutters using a
bimodal test on the run's *own* gap distribution (real gutters run
3.4-4.4x the in-cell word gap; fully-justified prose, the one reliable
counterfeit, tops out ~1.3x). A fixed threshold cannot work here — the
same 4pt gap is a gutter in 6pt type and an ordinary space in 12pt type.

Wired into the three places that previously reasoned over raw spans:
track inference, strong-row detection, and cell assignment.

opendataloader-bench TEDS 0.7441 -> 0.7471, NID +0.0009; ParseBench table
composite 0.4034 -> 0.4063. Two docs up, none down on either benchmark.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014H7NdfNMMWsCKZccGXDvsY
…mpty

The ruled-grid density gate counted a rowspan continuation cell as a
failed text assignment, so a table with a merged label column ("1.
Embodying sustainability values" beside three competence rows) died as
mostly-empty and its text spilled into prose.

`rowspan_mask` reads the merge straight off the geometry: a cell whose
top boundary carries no horizontal stroke over its own centre, at a
boundary some vertical rule runs through, is merged with the cell above.
The mask rides on `CellGrid` so it survives the row/column collapses and
the gate forgives the empties that are actually present.

Two guards are load-bearing, each found by a document it broke:
- the vertical-continuation test, without which a page-frame component
  shredded body prose into a two-column table;
- "the merged head must hold text", without which a chart's vertical
  gridlines produced a 27-column table (-0.0072 NID over 4 docs).

Also prefer real word x's over character-index interpolation when
splitting a run across column anchors. Byte-inert on odl-bench (round
3's gutter split already consumes that class) but geometrically correct
where it does fire.

odl-bench TEDS 0.7471 -> 0.7526, NID/MHS flat, one doc up, none down.
ParseBench table composite 0.4063 -> 0.4074.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRj9HatWAHXGNyLbJVB1k7
…d pass

The global ruled pass builds doc 200's table whole, then throws it away
because `already_handled` finds that *some* xy_cut leaf tables on its
own. The leaf in question was the landscape slide's title band — 3 of
the run's 58 lines, which "tables" as a row of gutter-split title
fragments. The real table's body rows then spilled out as prose.

Require the vetoing leaf to hold at least a quarter of the run's lines.
The decorative-frame case the veto defends against is unaffected: there
the data region is the majority of the frame's content.

odl-bench TEDS 0.7526 -> 0.7579 (doc 200 alone 0.054 -> 0.276, NID
+0.097, MHS -0.143 as its titles move into the table), nothing else
moves. ParseBench table composite flat at 0.4073.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRj9HatWAHXGNyLbJVB1k7
A table drawn as nothing but a top and bottom hairline has no grid for
the ruled detector, and when it has only two columns it is below
TABLE_MIN_COLUMNS for the borderless one. Doc 165 scored 0.000 for this
reason — round 3's word-geometry splitter recovers both of its column
tracks from the merged header run, and they were then thrown away.

The rules are the missing evidence. `rule_bands` pairs two horizontal
rules with no vertical crossing between them: a band with verticals is a
real grid and the ruled detector owns it. Inside such a band, and only
in the index gaps the normal pass left empty, retry detection with the
column minimum lowered to two and single-piece body rows allowed (a
blank second column makes every body row one piece).

Detection runs against the band's lines alone, so a run can never reach
past the rules that justify the relaxation in the first place. Three
further gates: the band's lines must be contiguous, number at least
three (a lone rule under a heading and stacked hyperlink underlines are
the counterfeits), and the seed must read as a genuine two-cell row.

odl-bench TEDS 0.7579 -> 0.7817; doc 165 alone 0.000 -> 1.000, matching
pdf-inspector, with NID +0.046 and nothing else moving. ParseBench table
composite flat at 0.4074. 308 lib tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRj9HatWAHXGNyLbJVB1k7
A slide deck routinely draws a table as one stroked rect per cell, so a
decorative highlight box behind a phrase *inside* a cell contributes a pair
of vertical edges that split that column for the whole table. Doc 200 had a
21.6x9.4pt box shred a 210pt `Explanation` column, taking the grid from 4
columns to 6; `collapse_gutter_columns` cannot fuse the sliver back because
text centres do land inside it.

Add `RULED_VLINE_MIN_COVERAGE`, the mirror of the existing
`RULED_HLINE_MIN_COVERAGE`: a vertical rule must span 20% of the component's
row extent to count as a column boundary.

Applying the filter directly is what does *not* work — dropping evidence can
turn a grid the gates rejected into one they accept. Three documents found
that the hard way (a bar chart released its gridlines as thematic breaks, a
pie chart became a junk 2-column table, and a ParseBench insurance page grew
an 11-column table that swallowed the whole page). Raising the fallback
threshold patched them one at a time and kept finding new ones. Instead the
unfiltered build now runs first and gatekeeps: the filtered grid may only
ever *replace* a table that would have been produced anyway, and only when
it measurably lowers the straddle census, so a stub sitting on a real column
edge in a dense financial table is left alone.

odl-bench: TEDS 0.7817 -> 0.7857, NID +0.0006, MHS +0.0000, one doc changed.
ParseBench table composite: 0.4074 -> 0.4074 (flat).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF4eSJHLg1tQmR4iG5ATC6
A table drawn with interior dividers but no outer frame yields `xs` that
stop at the first and last vertical rule, so its outermost columns are
missing entirely and every line in them reads as overhang. Doc 182's 4x4
table came out as the 2x2 grid its interior rules literally describe, and
all 31 lines tripped the overhang guard before the component collapsed.

The evidence is already on the page and nothing read it: the horizontal
rules know how wide the table is, and the verticals know how tall. Extend
each axis to the *median* extent of the perpendicular rules.

Three guards, each load-bearing:
  - median, not min/max, so one overshooting stroke cannot widen the grid;
  - the new outer band must hold a span centre — the same idiom
    `collapse_gutter_columns` uses — or a pen-cap overshoot manufactures an
    empty outer column;
  - the band must be at least as tall as the shortest existing row. Verticals
    routinely overrun the last horizontal by a few points; doc 45 turned a
    15.5pt tail on 26pt rows into a phantom row, which was enough for the
    ruled grid to outrank a better borderless table and merge five rows into
    one. The column axis takes no such floor, because an unruled label column
    is both common and legitimately much narrower than its data columns
    (doc 182's is 94pt against 259pt).

odl-bench: TEDS 0.7857 -> 0.7980, NID +0.0008, MHS +0.0007, one doc changed
(182: 0.247 -> 0.762), nothing down.
ParseBench table composite: 0.4074 -> 0.4144, the largest single-change gain
so far — four pages of one insurance filing go from ~0.09-0.42 to ~0.99.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF4eSJHLg1tQmR4iG5ATC6
`cluster_v_segments` merges same-x verticals by taking the union of their
y-ranges with no gap check. Two tables stacked in one column — each drawing
its own short strokes at the same left edge — therefore fuse into a single
grid component across a band of blank page. Each table is then evaluated
with all of the other's rows empty and dies on the empty-cell fraction, and
where the two tables have different layouts their column sets are unioned
too, over-segmenting both. Docs 81-84 are one report, all four broken this
way by a single 113pt gap; doc 81's `Number | of clauses` split across two
columns, recorded earlier as a separate detector-priority problem, is the
same union and needs no priority plumbing.

Cut the component wherever a band between consecutive horizontals is spanned
by no raw, pre-cluster vertical. That is strictly stronger than "a big gap",
and unlike a gap rule it cannot fire on a rowspan, where the vertical does
continue through the boundary. Each band's verticals are then clipped to the
band, so the shared spine cannot carry the neighbour's height back in
through the coverage filter or the outer-extent extension.

Guards: median row pitch >= 8pt (vector-drawn maths glyphs make components
with a 3-4pt pitch), the gap must clear both 2.5x the pitch and 30pt, and
every band must keep >= 2 rows or the split is abandoned whole. This is the
class the round-4 row-trim experiment failed on: a post-hoc trim keeps the
unioned columns, which is why it kept losing docs 81 and 127. Splitting the
component leaves 127, 130, 165 and 188 untouched.

odl-bench: TEDS 0.7980 -> 0.8134 (pdf-inspector is 0.8141), NID +0.0012,
MHS flat. Docs 82/84 reach 1.000, 83 0.995, 81 0.935.
ParseBench table composite: 0.4144 -> 0.4171.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF4eSJHLg1tQmR4iG5ATC6
Three changes, all the same shape: the geometry already answered the
question and no code was reading it. TEDS 0.7817 -> 0.8134 against
pdf-inspector's 0.8141, so the gap is now -0.0007 and we lead on every
metric. ParseBench table composite 0.4074 -> 0.4171.

Closes followup 8 (and corrects its diagnosis), rewrites followup 3 now that
doc 200's columns are fixed, and adds diagnosed-but-unbuilt entries for docs
119 and 197 including the measurement showing 197's ceiling is a
ground-truth idiosyncrasy rather than a defect worth chasing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF4eSJHLg1tQmR4iG5ATC6
The `liteparse` skill was documented in two repos with divergent prose and no
link between them, and this repo's own landing page never mentioned it — the
only way in was a single link from Getting Started.

- Expand the guide with requirements (Node 18+, LibreOffice, ImageMagick, uv),
  the manual SKILL.md install path, the agent plugin, and a summary of the
  extraction patterns the skill actually teaches
- Add the guide to the "Get started" list on the LiteParse index page
- Link the guide from the README's Agent Skill section
- Link out to the agent tooling hub at developers.llamaindex.ai/for-agents/

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013vZtD6UvLVCEdWLRGd7Q32
Follow-up to 8ad92a3 / d9ee62e, reworking the flatten-based approach
after review.

Why flattening at all, rather than reading field values? The parser
already exposes `page.form_fields()` with names, values and rects, and
synthesizing text items from those would be a fraction of this code.
It is wrong in both directions, though: a field can carry a /V that no
appearance paints (an unpainted default is not visible text, and would
be injected), and a painted appearance carries layout that the value
alone loses — line breaks, comb spacing, and the widget's own font
metrics. Flattening asks PDFium for what is actually painted, which is
the same question the text layer answers everywhere else. Both cases
are pinned by fixtures.

Correctness:

- FPDFPage_Flatten and FPDFAnnot_SetFlags now load via load_fn_opt.
  Loading them with load_fn! made them mandatory, so any pdfium build
  without fpdf_flatten.h failed PdfiumBindings::load outright and no
  document parsed at all. They follow the SignatureApi pattern instead,
  and a build without them just skips flattening.

- The widget appearance walk descends into nested form XObjects.
  Acrobat and several server-side fillers emit `/Tx BMC q /Fm0 Do Q EMC`,
  where the top-level object is a form, not text; those filled fields
  looked empty and were never flattened.

- has_annotation_text keeps its original HIDDEN-only semantics. The
  shared helper had widened it with INVISIBLE and NOVIEW, which silently
  changed the AnnotationText complexity signal — i.e. OCR routing — for
  non-widget annotations. The stricter mask now lives only on the
  form-widget path.

- Page text that flattening suppressed is restored. PDFium's text layer
  emits only one of two runs starting at essentially the same point, so
  a flattened appearance can knock out page text it lands on. Where the
  strings match (a producer that wrote the value into both the content
  stream and the appearance) that is the dedup a partially flattened
  file needs; where they differ it is data loss, and the pre-flatten
  copy is put back.

Cost:

- Documents with no AcroForm catalog cost one form_type() call and never
  reach the annotation walk.

- is_complex no longer reopens the input. Complexity now runs on the
  flattened document deliberately: AnnotationText means "the text is
  there, just outside the extractable surface", which stops being true
  once the value is in the content stream. Routing such a page to OCR
  re-derives text the parser already returned.

- parse reopens the input only when OCR *and* render_form_fields are on,
  the one consumer that needs live widgets (it initializes the form
  environment to run document actions and paint computed appearances).
  Plain OCR rendering does not: a flattened page rasterizes the same.

- Recovering suppressed text needs the page's text twice, so it is
  gated on a bounds-only probe for a text object overlapping a widget
  rect. The usual form page, whose widgets sit over blank space, pays a
  page-object walk instead of a second extraction.

Also: the 4-tuple return is now ExtractedPages; the flatten/reload dance
moved behind Document::flatten_form_widgets so no caller can hold a
stale page handle; and the fixture is generated by a checked-in script
documenting what each widget exercises, so its exact counts are
auditable.

Verified each fix fails the suite when reverted individually.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TXb7LewB9rTYJY1hY9Dbz
hexapode and others added 26 commits August 10, 2026 16:16
- Rename the session primitive open() -> openBatchSession /
  open_batch_session on every surface (core, napi, wasm, pyo3) so it
  reads as batch-specific plumbing, and mark it internal in the Node
  and Python bindings where parseBatches()/parse_batches() is the
  public API.
- Add ParseSession.close() to the napi binding and call it from
  parseBatches()'s finally block, so the converted-PDF temp file is
  released when iteration ends (including early break) instead of at GC.
- Fix the Python native/wrapper ParseBatch name collision by renaming
  the pyo3 classes to _ParseBatch/_ParseSession.
- Make Python parse_batches() validate and open eagerly so missing-file
  and target_pages errors raise at call time, not on first iteration;
  document the equivalent generator laziness on the Node side.
- Replace the office-conversion integration test with a unit test that
  asserts the actual invariant: the converted temp PDF outlives every
  batch and is removed when the session drops.
- Document wasm ParseSession cleanup (free()) and its
  one-batch-at-a-time constraint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfJUQBVV5URPba1iRqDE6w
# Conflicts:
#	crates/liteparse-python/src/lib.rs
#	crates/liteparse-wasm/src/lib.rs
#	crates/liteparse/src/parser.rs
#	packages/node/native.d.ts
#	packages/node/src/lib.ts
#	packages/python/liteparse/__init__.py
Review follow-ups on continue_on_page_error:
- Roll failed pages out of the image dedup cache so later duplicates
  never reference a dropped canonical image id; per-page extraction is
  now a named fn whose side effects (images, error counts, flattened
  flag) commit only on success instead of via manual rollback
- Honor the flag in the OCR render and complexity passes (page keeps
  its native text, stats attach by page number) and in is_complex()
- Warn on stderr when pages are skipped so text/markdown CLI output is
  not silently missing pages (Rust, Node, and Python CLIs)
- Wire --continue-on-page-error into the Node and Python lit CLIs
- Serialize PageError page number as `page` to match sibling per-page
  JSON fields; bindings keep their existing pageNum/page_num convention
- Deduplicate Node parsePages result mapping through toParseResult

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAa9HQf765nP4AEMiuUZEk
Resolves config/binding conflicts with run-llama#409 (bounded-memory batch
parsing) and run-llama#410 (tolerant page errors). Semantic fixes on top of the
textual resolution:

- packages/node: parse()/parsePages() use main's toParseResult helper,
  which now also maps screenshots
- render_document_pages takes continue_on_page_error and skips pages
  that fail to render (mirrors render_pages_for_ocr), so tolerant-mode
  parses no longer abort on a screenshot render failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcsmXwKScQ1cVQksqCXhFy
The render module has no wasm-incompatible dependencies (encode_png was
already always-compiled and the OCR path already rasterizes pages on
wasm), so compile it for wasm32 and drop the cfg split that silently
returned no screenshots there. Only the standalone screenshot()/
screenshot_input() APIs stay native-only — they go through LibreOffice
conversion.

Exposes extractScreenshots + detectScreenshotRects and the
screenshots result field in the wasm binding. Verified end-to-end with
a Node smoke run of the wasm-pack build (PNG magic, page count,
default-off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcsmXwKScQ1cVQksqCXhFy
…an rendering

Review follow-ups to the code-span escaping fix:

- apply_style's mono branch used a hardcoded 2-backtick fence, so mono
  content containing a 2-backtick run closed the span early — and with
  bodies now unescaped, the spilled tail rendered as live markdown.
  The fence is now one backtick longer than the longest run in the
  content (single-backtick output is byte-identical to before).

- The style_body + is_plain + apply_style pattern was repeated at all
  three call sites, leaving apply_style with an undocumented asymmetric
  precondition (escaped input for non-mono, raw for mono). Folded into
  render_span(), the single chokepoint call sites should use. The
  is_plain guard was output-identical to apply_style's plain arm, so
  this is a pure refactor.

Markdown output for apple-10k-2024, long_tiny_text, annotation_text,
and sample is byte-identical before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQY3NuFuvKBWXdihAH3tYK
@Chengyunlai
Chengyunlai force-pushed the codex/fix-separated-grid-components branch from 514a2ef to 9d740a4 Compare August 13, 2026 09:23
Chengyunlai and others added 3 commits August 15, 2026 13:28
`reorder_independent_table_lines` sorted table-owned lines by grid rank but
wrote them back into their original slot positions, which left two problems.

A non-table line interleaved between table rows kept its slot while the
table lines moved around it, stranding it mid-table. A page-spanning note
between the rows of two side-by-side grids rendered as a bogus table row in
the first table and collapsed the second one to loose paragraphs entirely —
worse than the fused-table output it replaced.

The sort key also omitted `region_path`, so a line could land in a different
xy-cut leaf's slot range. `markdown_layout` recovers regions by scanning for
maximal runs of equal `region_path` (`classify.rs`), so that shatters a leaf
into phantom regions and misaligns the region-relative table runs keyed off
it.

Reorder one leaf at a time, and only over the span between that leaf's first
and last table line, bailing out when anything page-spanning sits inside the
span. Leaf contiguity is preserved by construction and the ambiguous pages
are left in projection order rather than actively broken.

Also resolve table ownership once per item instead of rescanning
`table_rects` for every item of every y-band, which made line construction
quadratic in band size.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVxkZQtLBRgR6YTLnvdWZB
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.

4 participants