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
|
|
@ -136,6 +136,20 @@ DEFAULT_OUTLINE_RUN = 3
|
|||
DEFAULT_TABLE_GRID = True
|
||||
DEFAULT_UNIT_FOLD = True
|
||||
|
||||
#: Round 4's two PDF READERS, both off. They are not arms and they are not in
|
||||
#: the paragraph above: an arm changes how the proposer cuts a text, and these
|
||||
#: two change what the text SAYS -- so they sit before every arm rather than
|
||||
#: beside them, and a measurement of an arm is a measurement of it reading
|
||||
#: whatever these produced.
|
||||
#:
|
||||
#: `DEFAULT_PDF_HEADINGS` is off BY MEASUREMENT and the measurement is in
|
||||
#: `docs/2026-09-08-k3-runde4-pdf-skrift-og-ocr.md`, not by caution.
|
||||
#: `DEFAULT_OCR` is off BY CONSTRUCTION and can never move: the engine is an
|
||||
#: optional dependency group, so a default that needed it would make the
|
||||
#: package's ordinary install fail on the first scanned page.
|
||||
DEFAULT_PDF_HEADINGS = False
|
||||
DEFAULT_OCR = False
|
||||
|
||||
#: The timestamp written when the caller passes none, for the ingest stamp and
|
||||
#: the proposal stamp alike. ONE constant: two independently-defaulted literals
|
||||
#: drift, and the drift shows up only as two bundles differing in a field
|
||||
|
|
@ -156,6 +170,8 @@ def _propose_plans(
|
|||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
pdf_headings: bool = False,
|
||||
ocr: bool = False,
|
||||
) -> tuple[int, int, int]:
|
||||
"""Propose a plan per dropped file. Returns (written, nothing, failed).
|
||||
|
||||
|
|
@ -186,6 +202,8 @@ def _propose_plans(
|
|||
keep_table_heading=keep_table_heading,
|
||||
sheet_section_rows=sheet_section_rows,
|
||||
drop_wrapped_outline=drop_wrapped_outline,
|
||||
pdf_headings=pdf_headings,
|
||||
ocr=ocr,
|
||||
)
|
||||
except ProposerError as exc:
|
||||
print(f"{CLI_ID}: {relative.as_posix()}: {exc}", file=sys.stderr)
|
||||
|
|
@ -215,6 +233,8 @@ def build(
|
|||
keep_table_heading: bool = False,
|
||||
sheet_section_rows: bool = False,
|
||||
drop_wrapped_outline: bool = False,
|
||||
pdf_headings: bool = DEFAULT_PDF_HEADINGS,
|
||||
ocr: bool = DEFAULT_OCR,
|
||||
) -> CorpusReport:
|
||||
"""Folder in, bundle out. The whole command, minus argument parsing.
|
||||
|
||||
|
|
@ -233,7 +253,14 @@ def build(
|
|||
if proposed_at is None:
|
||||
proposed_at = ingested_at
|
||||
if not segments:
|
||||
report = measure(inbox, bundle, ingested_at=ingested_at, profile=STRUCTURED_V1)
|
||||
report = measure(
|
||||
inbox,
|
||||
bundle,
|
||||
ingested_at=ingested_at,
|
||||
profile=STRUCTURED_V1,
|
||||
pdf_headings=pdf_headings,
|
||||
ocr=ocr,
|
||||
)
|
||||
_write_log(bundle, report, profile=STRUCTURED_V1)
|
||||
return report
|
||||
|
||||
|
|
@ -264,6 +291,8 @@ def build(
|
|||
keep_table_heading=keep_table_heading,
|
||||
sheet_section_rows=sheet_section_rows,
|
||||
drop_wrapped_outline=drop_wrapped_outline,
|
||||
pdf_headings=pdf_headings,
|
||||
ocr=ocr,
|
||||
)
|
||||
print(
|
||||
f"{CLI_ID}: proposed {written} plan(s); {nothing} document(s) with no boundary; "
|
||||
|
|
@ -278,6 +307,8 @@ def build(
|
|||
plans=plans,
|
||||
profile=SEGMENTED_OKF_V0_2,
|
||||
root_frontmatter_values={"okf_version": okf_version, "bundle_id": bundle_id},
|
||||
pdf_headings=pdf_headings,
|
||||
ocr=ocr,
|
||||
)
|
||||
_write_log(bundle, report, profile=SEGMENTED_OKF_V0_2)
|
||||
return report
|
||||
|
|
@ -474,6 +505,37 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
"and the risk-table rows the operator wrote off"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--pdf-headings",
|
||||
choices=("none", "font"),
|
||||
default="font" if DEFAULT_PDF_HEADINGS else "none",
|
||||
help=(
|
||||
"how a PDF's headings are recovered before any arm reads the text. "
|
||||
"none (the default) is today's extraction: a PDF carries no heading "
|
||||
"markup and none is inferred. font infers one from TYPOGRAPHY -- a "
|
||||
"line whose dominant size is above the document's body median AND "
|
||||
"whose dominant font name says bold -- and emits it as an ATX "
|
||||
"heading in the same markdown the office path produces, so the "
|
||||
"existing heading rule reads it and no PDF-only grammar exists. A "
|
||||
"CONJUNCTION, measured: adding weight as a disjunct took precision "
|
||||
"from 0.786 to 0.524"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--ocr",
|
||||
action="store_true",
|
||||
default=DEFAULT_OCR,
|
||||
help=(
|
||||
"read a PDF page as an IMAGE when its own text never arrived -- the "
|
||||
"page extracts empty, or as a page of (cid:N) placeholder codes. "
|
||||
"Requires the optional 'ocr' group (pip install "
|
||||
"'llm-ingestion-okf[extract,ocr]'); without it every affected file "
|
||||
"is a coded rejection, never a crash. Never a default and never "
|
||||
"able to become one: the engine is an optional dependency. OCR text "
|
||||
"is a reading of a picture, so it is deterministic within one model "
|
||||
"version and not across one"
|
||||
),
|
||||
)
|
||||
build_parser.add_argument("--report", type=Path, default=None, help="also write the report")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
|
@ -503,6 +565,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
keep_table_heading=args.keep_table_heading,
|
||||
sheet_section_rows=args.sheet_section_rows,
|
||||
drop_wrapped_outline=args.drop_wrapped_outline,
|
||||
pdf_headings=args.pdf_headings == "font",
|
||||
ocr=args.ocr,
|
||||
)
|
||||
except (IngestError, OSError, ValueError) as exc:
|
||||
print(f"{CLI_ID}: FAILED - {exc}", file=sys.stderr)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue