fix(extract): a bookmark that shared a line left no trace, so count it

GREEN on the two tests from the previous commit. `PdfOutline` gains
`collided`, and the identity it buys is the point rather than the counter:
NODES IN == len(marks) + unresolved + collided. Measured on R761 with the
shipped function, 2 763 == 2 762 + 0 + 1, where before the same document
reported 2 762 marks and 0 unresolved and said nothing about the third
number. The lost node was `SVV - Forside`, sharing line 0 with the tree's own
root node.

THE RULE WAS CHOSEN BY MEASUREMENT, NOT BY ARGUMENT. Keeping both nodes as
sibling marks was run through `find_candidates` on R761's own text and text:
the candidate list goes 2762 -> 2763, and the first candidate closes with an
EMPTY span (0, 0) because two candidates open at one offset. The orphan check
deletes an empty span in silence, so keeping both loses the same node one step
later and adds a front-matter title that is in no fasit. First-in-tree-order
wins, the loser is counted.

Known-negatives, all measured rather than assumed: `outlined-krav.pdf` and
`outline-broken-dest.pdf` unchanged in outcome and reporting `collided == 0`;
`~/okf-test/dokumenter` byte-identical against a frozen `e1f4faa` export
(`diff -r` empty, 52 md); the pinned K2 bundle's concept count and per-row
ranks unchanged (7 passed). The arm stays off by default and no boundary rule
moved.

pytest -q: 1556 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-10 03:23:31 +02:00
commit 525d68ee90

View file

@ -679,10 +679,22 @@ class PdfOutline:
and raising would refuse a file over a defect in one of its bookmarks. and raising would refuse a file over a defect in one of its bookmarks.
Dropping silently is the third option this library refuses everywhere else, Dropping silently is the third option this library refuses everywhere else,
so the count is part of the return value. so the count is part of the return value.
`collided` is the same principle applied to the OTHER way a node leaves
without a boundary. Two bookmarks can resolve to one line -- measured on
the 701-page process code, its tree's root node and `SVV - Forside` both
land on line 0 -- and only the first can become a mark, because two
candidates at one offset give the first an empty span that the orphan check
then deletes without a word. That was measured on that document and is why
keeping both was felled rather than argued. What the count buys is the
identity: NODES IN == len(marks) + unresolved + collided, so a document
that loses several nodes this way says so instead of returning a shorter
list that looks complete.
""" """
marks: tuple[OutlineMark, ...] marks: tuple[OutlineMark, ...]
unresolved: int unresolved: int
collided: int = 0
def _outline_page_and_top(doc: object, dest: object, action: object) -> tuple[object, float | None]: def _outline_page_and_top(doc: object, dest: object, action: object) -> tuple[object, float | None]:
@ -770,6 +782,7 @@ def pdf_outline(
offset += len(page_lines[number]) + 1 offset += len(page_lines[number]) + 1
unresolved = 0 unresolved = 0
collided = 0
placed: dict[int, OutlineMark] = {} placed: dict[int, OutlineMark] = {}
with pdfplumber.open(io.BytesIO(data)) as pdf: with pdfplumber.open(io.BytesIO(data)) as pdf:
try: try:
@ -835,9 +848,14 @@ def pdf_outline(
# FIRST in tree order wins a shared line. Two marks on one line # FIRST in tree order wins a shared line. Two marks on one line
# would give the second an empty span, and the orphan check # would give the second an empty span, and the orphan check
# deletes an empty span silently -- the same trap the proposer # deletes an empty span silently -- the same trap the proposer
# documents for a second-pass candidate list. # documents for a second-pass candidate list. The loser is
placed.setdefault(at, OutlineMark(line=at, level=level, title=title)) # COUNTED rather than dropped: `setdefault` alone made a lost
return PdfOutline(tuple(placed[at] for at in sorted(placed)), unresolved) # node indistinguishable from a node that was never there.
if at in placed:
collided += 1
continue
placed[at] = OutlineMark(line=at, level=level, title=title)
return PdfOutline(tuple(placed[at] for at in sorted(placed)), unresolved, collided)
def _normalise_outline(value: str) -> str: def _normalise_outline(value: str) -> str: