Skip to content

pdf: first data row of a continuation table is silently dropped when the header is not repeated #214

Open

Description

@azoth-gridflowtech

Main previously rechecked 2026-10-29 at e7178e0 (Cargo.lock unchanged, pdf-inspector 1.14.2). Current PyPI release: firecrawl-anydoc 0.2.4. The current-main result below was reproduced with the Rust core example, not a rebuilt Python wheel.

Latest release independently rechecked 2026-10-30: firecrawl-anydoc 0.2.4 installed into a new isolated Python 3.14.4 venv. Running check_rowdrop.py reproduced both noheader modes missing 008/016, while both repeated-header controls retained all 24 rows. Main remains e7178e0; its earlier source-build evidence was not rerun.

Problem
A table across three table-only PDF pages has its header on page 1 only. Conversion silently removes the first data row on pages 2 and 3. Repeating the header on each page prevents the loss. Both ruled and unruled tables show the same result.

Minimal reproduction
Save the inline stdlib-only generator below as mkpdf.py. All fixture data are synthetic.

python3 mkpdf.py noheader.pdf noheader
python3 mkpdf.py header.pdf header
python3 mkpdf.py noheader_unruled.pdf noheader_unruled
python3 mkpdf.py header_unruled.pdf header_unruled
cargo build --locked --example convert
target/debug/examples/convert noheader.pdf

Expected: all 24 rows, Widget 000 through Widget 023, exactly once.
Observed on current main:
noheader: 22 rows; missing Widget 008 and Widget 016
header: 24 rows; none missing
noheader_unruled: 22 rows; missing Widget 008 and Widget 016
header_unruled: 24 rows; none missing
No warning or conversion error was emitted.

Likely owning component
src/formats/pdf.rs delegates Markdown extraction to pdf-inspector; this PDF path bypasses anydoc's shared table/header model. In pdf-inspector 1.14.2, src/markdown/convert.rs, merge_continuation_tables (lines 598-607) unconditionally skips the first two Markdown lines of each continuation table. The first line can be data rather than a repeated header. This matches the deterministic row loss above.
A possible repair is to keep the first row when it does not match the original table header, while removing the Markdown separator. A dependency update may be the right integration route if this is already repaired upstream; newer pdf-inspector versions were not tested here.

Duplicate check (2026-10-30)
Reviewed the complete paginated anydoc issue/PR inventory: 208 unique records, including 126 open. No matching continuation-row-loss report was found in titles/bodies. Issue #173 concerns multi-column reconstruction, not this first-row loss. Merged PR #196 changes shared CSV/xlsx/ods header voting and does not affect the PDF path. Open PRs #175 and #181 update pdf-inspector for RTL extraction; they were not built or claimed to fix this bug.

Synthetic PDF generator

"""Write a synthetic multi-page ruled table PDF (stdlib only, no PII).
usage: mkpdf.py OUT MODE   MODE: noheader | header | noheader_unruled | header_unruled"""
import sys
out, mode = sys.argv[1], sys.argv[2]
ruled = 'unruled' not in mode; rep_header = mode.startswith('header')
COLS = [50, 170, 260, 400, 520]; W = 560; RH = 24
HEAD = ['Item', 'Version', 'Region', 'Start', 'End']
def row(n): return [f'Widget {n:03d}', f'{1+n%3}.{n%7}', 'XX', f'{1+n%12}/{1+n%28}/2025', f'{1+(n+5)%12}/{1+n%28}/2026']
pages=[]; n=0
for p in range(3):
    rows=[HEAD] if (p==0 or rep_header) else []
    for _ in range(8): rows.append(row(n)); n+=1
    ops=['BT /F1 10 Tf ET']; y=740
    for r in rows:
        if ruled:
            for i,x in enumerate(COLS):
                x2 = COLS[i+1] if i+1<len(COLS) else W
                ops.append(f'{x-4} {y-7} {x2-x} {RH} re S')
        for x,c in zip(COLS,r): ops.append(f'BT /F1 10 Tf {x} {y} Td ({c}) Tj ET')
        y-=RH
    pages.append('\n'.join(ops).encode())
objs=[]
def add(b): objs.append(b); return len(objs)
font=add(b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')
pages_id=len(objs)+1+2*len(pages)
kids=[]
for c in pages:
    cid=add(b'<< /Length %d >>\nstream\n'%len(c)+c+b'\nendstream')
    kids.append(add(b'<< /Type /Page /Parent %d 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 %d 0 R >> >> /Contents %d 0 R >>'%(pages_id,font,cid)))
assert add(b'<< /Type /Pages /Kids [%s] /Count %d >>'%(b' '.join(b'%d 0 R'%k for k in kids),len(kids)))==pages_id
cat=add(b'<< /Type /Catalog /Pages %d 0 R >>'%pages_id)
buf=bytearray(b'%PDF-1.4\n'); offs=[]
for i,o in enumerate(objs,1):
    offs.append(len(buf)); buf+=b'%d 0 obj\n'%i+o+b'\nendobj\n'
x=len(buf); buf+=b'xref\n0 %d\n0000000000 65535 f \n'%(len(objs)+1)
for o in offs: buf+=b'%010d 00000 n \n'%o
buf+=b'trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n'%(len(objs)+1,cat,x)
open(out,'wb').write(buf)

Python release reproduction: install firecrawl-anydoc==0.2.4, generate each fixture as above, then call anydoc.to_markdown on each PDF.

Update 2026-10-30: pdf-inspector main bcf387d (latest release v1.25.2) still has the same unconditional skip in merge_continuation_tables (src/markdown/convert.rs, the line_idx >= 2 loop near line 670). This is from reading the code only. I did not build or run the newer version.

Activity

  1. Aschenbath commented on Oct 31, 2026

    @Aschenbath

    The row loss comes from merge_continuation_tables in pdf-inspector. It is still on pdf-inspector main, so updating the dependency alone will not fix it. Fix proposed in firecrawl/pdf-inspector#648: a continuation page's first row is now dropped only when it matches the first page's header. With your generator, noheader.pdf now gives all 24 rows. Once that is released, anydoc will need to bump pdf-inspector.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions