feat(extract,cli): typography as a PDF heading source and OCR behind an optional group, both off
A PDF carries no notion of a heading -- a heading in a PDF is a typographic
fact -- so the text stream `pdfplumber` hands the segment proposer has already
thrown away the only evidence there was. The `docx` path never had that problem:
the converter emits ATX headings and `_ATX` cuts on them. Two readers close the
gap, and both are OFF.
`--pdf-headings font` infers a heading from the conjunction this repository
already measured (size above the document's character-weighted body median AND
a bold font name, recall 1.000 / precision 0.846) and emits it as ATX in the
SAME markdown the office path produces, so `_ATX` applies unchanged and no
PDF-only heading grammar exists.
It stays off BY MEASUREMENT, and the measurement is the point of the round:
against the operator's unit worksheet it takes `pdf` from 2 of 8 to 0 of 8,
losing two exact matches. The mechanism of the loss is stated rather than
guessed -- on those documents the outline rule already recovers the document's
own numbered chapters, so a second heading source can only add. Whole-corpus
screen: 25 of 32 `pdf` change, 0 of 5 `docx`, 0 of 2 `xlsx`. The default bundle
is byte-identical before and after this commit (`diff -r`, exit 0).
`--ocr` reads a page as an image when its own text never arrived: empty, or
`(cid:N)` placeholder codes at or above a threshold READ OFF a measured
distribution -- 834 pages over 32 files, 818 at exactly 0.0 and 16 at 0.93 or
above, nothing in between. On the one corpus document with the failure: 95.07 %
cid to 0 %, 44 to 2561 words of four or more letters, 17 to 18 pages with text.
Its engine is an optional dependency group and never a runtime dependency; a
packaging test pins both halves, and without the group every affected file is a
coded rejection (`extractor_ocr_group_missing`) rather than a crash.
Also corrects two stale published facts found while measuring: the README still
said two segmentation rules were on by default after `f6fea13` made it three,
and CLAUDE.md's K2 digest named the round-3 default. The current default is
492 concepts / 944 files, `bdefa679...`.
Report: docs/2026-09-08-k3-runde4-pdf-skrift-og-ocr.md
Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
f6fea13299
commit
53d5c74c96
15 changed files with 1394 additions and 28 deletions
39
tests/fixtures/font-heading-krav.pdf
vendored
Normal file
39
tests/fixtures/font-heading-krav.pdf
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 221 >>
|
||||
stream
|
||||
BT /F2 20 Tf 50 700 Td (Generelle tekniske krav) Tj ET
|
||||
BT /F1 10 Tf 50 670 Td (Utkilingen skal ha helning 1:15.) Tj ET
|
||||
BT /F2 14 Tf 50 640 Td (Merking) Tj ET
|
||||
BT /F1 10 Tf 50 610 Td (Kravet gjelder alle veiklasser.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000251 00000 n
|
||||
0000000522 00000 n
|
||||
0000000619 00000 n
|
||||
trailer
|
||||
<< /Size 7 /Root 1 0 R >>
|
||||
startxref
|
||||
721
|
||||
%%EOF
|
||||
54
tests/fixtures/make_fixtures.py
vendored
54
tests/fixtures/make_fixtures.py
vendored
|
|
@ -51,6 +51,57 @@ PAGED_CONTENTS = (
|
|||
)
|
||||
|
||||
|
||||
# Two fonts and three sizes on one page: a 20pt bold title, a 14pt bold
|
||||
# subheading, and 10pt regular body. The PDF format carries no notion of a
|
||||
# heading at all -- a heading in a PDF is a typographic fact, which is why the
|
||||
# font-aware reader has to infer one -- so a fixture for that reader must state
|
||||
# the typography and nothing else. The body is the majority of the characters,
|
||||
# which is what gives the reader a body size to compare against.
|
||||
FONT_HEADING_CONTENT = (
|
||||
b"BT /F2 20 Tf 50 700 Td (Generelle tekniske krav) Tj ET\n"
|
||||
b"BT /F1 10 Tf 50 670 Td (Utkilingen skal ha helning 1:15.) Tj ET\n"
|
||||
b"BT /F2 14 Tf 50 640 Td (Merking) Tj ET\n"
|
||||
b"BT /F1 10 Tf 50 610 Td (Kravet gjelder alle veiklasser.) Tj ET\n"
|
||||
)
|
||||
|
||||
|
||||
def build_two_font_pdf(content: bytes) -> bytes:
|
||||
"""A one-page PDF whose resources declare BOTH a regular and a bold font.
|
||||
|
||||
Separate from `build_paged_pdf` rather than a parameter on it: that builder
|
||||
emits exactly one font object and every existing fixture's bytes depend on
|
||||
its object numbering. A second font changes the numbering, so sharing the
|
||||
code would mean regenerating files whose whole value is that they have not
|
||||
moved.
|
||||
|
||||
The page is Letter-sized rather than the 200x200 the other fixtures use,
|
||||
because a 20pt line of this length does not fit inside 200 points and a
|
||||
character laid outside the page box is not one a reader has to see.
|
||||
"""
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
|
||||
b"/Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>",
|
||||
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"endstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
|
||||
]
|
||||
out = bytearray(b"%PDF-1.4\n")
|
||||
offsets = []
|
||||
for number, body in enumerate(objects, start=1):
|
||||
offsets.append(len(out))
|
||||
out += str(number).encode() + b" 0 obj\n" + body + b"\nendobj\n"
|
||||
xref_at = len(out)
|
||||
size = str(len(objects) + 1).encode()
|
||||
out += b"xref\n0 " + size + b"\n0000000000 65535 f \n"
|
||||
for offset in offsets:
|
||||
out += ("%010d 00000 n \n" % offset).encode()
|
||||
out += b"trailer\n<< /Size " + size + b" /Root 1 0 R >>\n"
|
||||
out += b"startxref\n" + str(xref_at).encode() + b"\n%%EOF\n"
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_pdf(content: bytes) -> bytes:
|
||||
"""Assemble a one-page PDF around `content` as the page content stream."""
|
||||
return build_paged_pdf((content,))
|
||||
|
|
@ -365,6 +416,9 @@ if __name__ == "__main__":
|
|||
(HERE / "three-page-krav.pdf").write_bytes(build_paged_pdf(PAGED_CONTENTS))
|
||||
print("wrote three-page-krav.pdf")
|
||||
|
||||
(HERE / "font-heading-krav.pdf").write_bytes(build_two_font_pdf(FONT_HEADING_CONTENT))
|
||||
print("wrote font-heading-krav.pdf")
|
||||
|
||||
for name, parts in (
|
||||
("two-line-krav.docx", _DOCX_PARTS),
|
||||
("no-styles-krav.docx", _DOCX_NO_STYLES_PARTS),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue