Skip to content

fix(spec): open search results on the correct PDF page - #12

Open
benglewis wants to merge 2 commits into
superdoc:mainfrom
benglewis:fix/pdf-page-navigation
Open

fix(spec): open search results on the correct PDF page#12
benglewis wants to merge 2 commits into
superdoc:mainfrom
benglewis:fix/pdf-page-navigation

Conversation

@benglewis

@benglewis benglewis commented Aug 9, 2026

Copy link
Copy Markdown

Search finds the right sections, but clicking one usually opens the wrong page. Two independent ingest faults, both measured against the published PDFs and the live API.

1. Page numbers are stored in the book's coordinates, then used as the file's

These PDFs carry two numbering systems: the number printed in the running header, and the physical sheet index that #page=N addresses. extract.py read the printed number; PdfViewer fed it straight into #page=. Nothing converted between them — grep -r offset returned nothing.

Sampling 70 random Part 1 sections and resolving each one's true page by scanning the PDF: the error was −11 in 52 of 69 sections and −10 in the other 17. Never zero, never positive.

spec_content.page_number keeps storing the printed page — the number a reader sees and cites. PdfViewer now adds the part's front-matter offset when building the fragment.

The four hardcoded page counts were also wrong:

Part Real sheets Was Front matter
1 5,026 5,560 10
2 137 129 8
3 44 65 6
4 1,548 4,031 14

Part 4 claimed 4,031 pages against an actual 1,548, so the scrubber addressed 2,483 positions that don't exist.

extract.py now extracts with page_chunks=True, so a line's physical page is known exactly rather than inferred from stray digits in the text, and measures the offset from the running headers (unanimous across all four parts: 5016/5016, 129/129, 38/38, 1532/1532). Each run prints the totalPages/pageOffset pair to copy into PDF_CONFIG when a PDF is replaced.

2. Half the corpus is the table of contents

Headings were matched by bold styling, and the contents listing is bold too — so every TOC line became its own section, carrying a title, a section ID, leader dots, and the page it points at. The guard meant to catch this is anchored to a leading digit (^\d+) while the lines start with **, so it never fired.

Across 12 representative queries, 66 of 120 results (55%) were TOC rows. They are excellent at matching — pure title text, so they outrank real prose — and useless at locating, since they carry the contents listing's own page, typically 50–100 pages adrift. §17.3.1.12 has four rows in production; the junk one stores page 166 while literally containing the right answer (219) after its leader dots.

Headings are now matched on structure, accepting both the bold and ATX (#### 17.3.1.12 Title) forms — current pymupdf4llm emits the latter, so this was version-fragile — and rejecting leader-dot titles.

Migration 0006 clears the rows already stored. The predicate was verified in Postgres against 539 production rows: 256 deleted, zero real prose caught, zero TOC rows left behind (identical to the Python predicate it was developed against).

3. Every chunk of a section claimed the section's first page

chunk.ts declared const currentPage = pageStart and never advanced it. Sections now carry inline <!--page:N--> markers and each chunk records the page it falls on — §17.3.1.12 goes from three chunks all claiming 218 to 219 / 221 / 223. Markers are stripped from stored content and embedding text.

Verification

  • 19/19 extracted section pages exact against the PDF; zero TOC leaks
  • SQL predicate cross-checked against the Python one on 539 production rows
  • Typecheck, build, and lint clean; test suite unchanged (the 13 DB-integration failures reproduce identically on main — they need a seeded TEST_DATABASE_URL)
  • Confirmed in the running app against the production API: §21.1.2.2.10 now resolves to sheet 3221 (true 3222) instead of 3211

Against the existing corpus the viewer now lands within one page — the residual ±1 is the old extractor's tracking lag baked into stored data. A re-ingest makes it exact. I could not run one: it needs embedding credits.

Notes for the reviewer

  • The migration deletes data. It ships as a file to apply manually, per the repo's convention. There's a dry-run SELECT in the header comment.
  • Removes fix-page-numbers.py, which duplicated the buggy page logic and would reintroduce wrong numbers if run.
  • Provenance mismatch, not addressed here: all four served PDFs are 4th Edition, December 2012, but data/sources.json declares 5th edition (2015–2021) with sha256 hashes of the 5th-edition zips. Section numbering and front-matter length differ between editions, so a re-ingest following the manifest would reintroduce exactly this class of bug. Worth deciding which edition the site should serve before the next ingest.

🤖 Generated with Claude Code

benglewis and others added 2 commits August 9, 2026 14:40
Search found the right sections but clicking one usually opened the wrong
page. Two independent ingest faults, both measured against the published
PDFs and the live API.

Printed vs physical pages
-------------------------
extract.py read the page number printed in the running header; PdfViewer
fed that straight into `#page=`, which addresses physical sheets. Nothing
converted between them, so every link landed in the front matter's worth of
pages early - measured at -11 in 52 of 69 sampled Part 1 sections and -10 in
the other 17. Never correct.

`spec_content.page_number` keeps storing the printed page (the number a
reader sees and cites). PdfViewer now adds the part's front-matter offset
when building the fragment. The four hardcoded page counts in PDF_CONFIG
were also wrong - Part 4 claimed 4031 pages against an actual 1548, which
broke the scrubber outright.

extract.py now extracts with `page_chunks=True`, so a line's physical page
is known exactly instead of inferred from stray digits, and measures the
offset from the running headers (unanimous across all four parts). It prints
the totalPages/pageOffset pair to copy into PDF_CONFIG when a PDF is
replaced.

Table-of-contents rows in the corpus
------------------------------------
Headings were matched by bold styling, and the contents listing is bold too,
so every TOC line became its own section - title, section ID, leader dots,
and the page it points at. The guard meant to catch this was anchored to a
leading digit while the lines start with `**`, so it never fired. Across 12
representative queries, 55% of results were TOC rows: excellent at matching
(pure title text, so they outrank real prose) and useless at locating.

Headings are now matched on structure, accepting both the bold and ATX forms
current pymupdf4llm emits, and rejecting leader-dot titles. Migration 0006
clears the rows already stored; its predicate was verified in Postgres
against 539 production rows - 256 deleted, no prose caught, none missed.

Chunk-level pages
-----------------
chunk.ts declared `const currentPage = pageStart` and never advanced it, so
every chunk of a section pointed at the section's opening page. Sections now
carry inline page markers and each chunk records the page it falls on:
17.3.1.12 goes from three chunks all claiming 218 to 219/221/223.

Verified: 19/19 extracted section pages exact against the PDF, zero TOC
leaks, typecheck and build clean. Against the existing corpus the viewer now
lands within one page; a re-ingest makes it exact.

Removes fix-page-numbers.py, which duplicated the buggy page logic and would
reintroduce wrong numbers if run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Annex pages repeat a bare "Annex A" as their running header. The annex
pattern accepted lines with no heading markup and allowed an empty title,
so each of those headers opened a new section - Part 3 produced four
"Annex A" entries, three of them empty, swallowing the annex body.

Require heading markup (# or **) and a non-empty title, and normalise the
captured letter so the ID is always "Annex A". Part 3 now yields exactly
one Annex A (page 33) and one Annex B (page 38), matching its contents
listing.

Caught while ingesting Parts 2-4 locally; Part 1's 19/19 page check is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@benglewis

Copy link
Copy Markdown
Author

@caio-pizzol @caiopizzol The search was broken due to contents pages not being part of the page numbers but being pages in the PDF. Please look at this PR that Claude wrote with a fix. If possible, merging this would be fantastic since we like your tool

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d74ad3181d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


# No leader dots, but still "Title 1047" - a contents entry whose title
# ran long enough to swallow the dots.
return bool(TOC_TRAILING_PAGE_RE.search(title))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve real headings that end in numbers

When a legitimate section title ends with a standalone numeric token, such as Version 1 or XML Schema 1.0, this predicate classifies it as a TOC entry even without leader dots. match_heading then returns None, so the section is omitted and its text is folded into the preceding section, corrupting both ooxml_search and ooxml_section; TOC detection needs additional context rather than rejecting every numeric-ending title.

AGENTS.md reference: AGENTS.md:L109-L113

Useful? React with 👍 / 👎.


annex = ANNEX_PATTERN.match(stripped)
if annex and has_heading_markup:
title = annex.group(2).strip().strip("*").strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip both emphasis groups from bold annex headings

When pymupdf4llm emits the supported form **Annex A** **(normative)**, the capture begins with ** **, and this strip("*") call stops at the intervening space; the resulting title is **(normative) rather than (normative). That malformed title is subsequently stored in spec_content and returned by the prose tools, so the annex markup should be parsed or normalized before persisting it.

AGENTS.md reference: AGENTS.md:L109-L113

Useful? React with 👍 / 👎.

@benglewis

Copy link
Copy Markdown
Author

This little Claude Artifact might help make sense of the issues that this resolves:
https://gist.github.com/benglewis/912ff09994ec452efc1d1287dea518d9

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.

1 participant