{"slug": "page-positions-page-labels-and-printed-numbers-are-different-pdf-data", "title": "Page positions, page labels, and printed numbers are different PDF data", "summary": "A developer's browser-based PDF extraction test found that a six-page synthetic PDF reduced to source positions 3, 4, and 6 produced a subset with the correct page count and matching extracted text, but with page labels COVER, i, and 1 instead of the expected 1, 2, and A-2. The source and subset shared identical /PageLabels /Nums entries, indicating the label rules were reinterpreted against the new page sequence rather than remapped to the selected source pages. The developer published a read-only pypdf 6.10.0 script, audit-labels.py, that compares output labels against the selected source labels under a preservation policy, while noting it is not a general PDF validator.", "body_md": "A PDF subset can contain exactly the right pages while reporting misleading page labels. Page-count and extracted-text checks can both pass.\n\nIn a small browser extraction check, a six-page synthetic PDF was reduced to source positions 3, 4, and 6. The saved PDF contained the expected chapter and appendix text. Its logical labels, however, were `COVER`, `i`, and `1`—the labels associated with the beginning of the source, not with the selected source pages.\n\n*AI disclosure: an AI agent prepared this article and the inspection code. The case uses fictional documents and two actual browser downloads. The code was exercised with pypdf 6.10.0; this article does not claim a production fix or a survey of PDF tools.*\n\nThree values can differ:\n\n`i`, `1`, or `A-2`.\nThese are separate inputs to an extraction contract. Changing the page list does not, by itself, rewrite the number printed in a footer. A reader's label also need not equal that printed number.\n\n[PDF.js documents the distinction in its API](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib-PDFDocumentProxy.html): `getPage()` takes a position starting at 1, `getPageIndex()` returns an index starting at 0, and `getPageLabels()` returns labels associated with those indexes. Treating a label as an array offset conflates different values.\n\nThe fixture's source labels were `COVER`, `i`, `1`, `2`, `A-1`, `A-2`. The intended policy for this check was to preserve each selected source page's label. Here is the observed mapping:\n\n| Output position | Source position | Label expected under preservation | Downloaded label | \n|---|---|---|---|\n| 1 | 3 | 1 | COVER | \n| 2 | 4 | 2 | i | \n| 3 | 6 | A-2 | 1 | \n\nBoth downloads reopened with three pages, and their complete extracted text matched the corresponding source pages. The input file's hash stayed unchanged. This establishes a label mismatch in that fixture, rather than a wrong-page selection or a modified source file.\n\nThe source and downloaded files also contained the same `/PageLabels` `/Nums` entries, including rules starting at source indexes 0, 1, 2, and 4. In this flat number-tree example, those rules were being interpreted against the new page sequence. The persisted dictionaries support that observation; they do not identify the exact writer function responsible for retaining the metadata.\n\nThe following read-only script expects readable PDFs and an ordered list of **1-based source positions**. It checks a preservation policy: the output labels must equal the selected source labels. It is not a general PDF validator.\n\nSave it as `audit-labels.py`. With Python and pypdf available, run it against your test files:\n\n```\npython audit-labels.py source.pdf subset.pdf 3 4 6\n```\n\nThe positions are separate command-line arguments, not a hyphenated range string. The inspected example was run with pypdf 6.10.0. Its [`page_labels` property](https://pypdf.readthedocs.io/en/stable/modules/PdfReader.html#pypdf.PdfReader.page_labels) returns labels in document order.\n\n``` python\nimport argparse\nimport json\nfrom pypdf import PdfReader\n\nparser = argparse.ArgumentParser(\n    description=\"Check a PDF subset against an ordered source-page selection.\"\n)\nparser.add_argument(\"source\")\nparser.add_argument(\"subset\")\nparser.add_argument(\"positions\", nargs=\"+\", type=int, help=\"1-based source positions\")\nargs = parser.parse_args()\n\nsource = PdfReader(args.source)\nsubset = PdfReader(args.subset)\nif any(n < 1 or n > len(source.pages) for n in args.positions):\n    parser.error(\"Every source position must be between 1 and the source page count\")\n\nexpected_text = [source.pages[n - 1].extract_text() for n in args.positions]\nactual_text = [page.extract_text() for page in subset.pages]\ntext_available = all(t and t.strip() for t in expected_text + actual_text)\nexpected_labels = [source.page_labels[n - 1] for n in args.positions]\nresult = {\n    \"page_count_matches\": len(subset.pages) == len(args.positions),\n    \"text_matches\": expected_text == actual_text if text_available else None,\n    \"expected_labels\": expected_labels,\n    \"actual_labels\": subset.page_labels,\n    \"labels_match\": expected_labels == subset.page_labels,\n}\nprint(json.dumps(result, ensure_ascii=False, indent=2))\nraise SystemExit(0 if all(result[k] is True for k in (\n    \"page_count_matches\", \"text_matches\", \"labels_match\"\n)) else 1)\n```\n\nFor the actual failing download, the report was:\n\n```\n{\n  \"page_count_matches\": true,\n  \"text_matches\": true,\n  \"expected_labels\": [\n    \"1\",\n    \"2\",\n    \"A-2\"\n  ],\n  \"actual_labels\": [\n    \"COVER\",\n    \"i\",\n    \"1\"\n  ],\n  \"labels_match\": false\n}\n```\n\nThe script exits 0 only if all three checks pass. Exit 1 means a mismatch or insufficient text evidence; invalid source positions are rejected by argparse with exit 2. A positive reference file with the selected labels passed. A reference with swapped pages failed, and source position 0 was rejected. These checks test the inspector, not a repaired production exporter.\n\nAn excerpt might intentionally receive labels `1`, `2`, `3`. That is a valid product choice if it is stated clearly. This preservation checker would reject that output by design; the mismatch does not make an otherwise readable PDF universally invalid.\n\nChoose the policy before comparing results. If you preserve source labels, remap them to the output positions. If you renumber the excerpt, generate a new label sequence. Neither choice automatically changes the printed footer. Also avoid treating labels as unique identifiers: the extraction mapping should refer to source pages, not depend on labels being unique.\n\nThis fixture has selectable text. For a scan or a deliberately blank page, an empty extracted string is not evidence that the visual content survived; the checker reports `text_matches: null` when the text comparison is not informative. It does not verify rendering, bookmarks, links, forms, signatures, or accessibility structure. Those require their own checks.\n\nThe useful regression is a source-to-output mapping with separate expectations for content and labels. A correct page count is only one field in that mapping.", "url": "https://wpnews.pro/news/page-positions-page-labels-and-printed-numbers-are-different-pdf-data", "canonical_source": "https://dev.to/yanwang/page-positions-page-labels-and-printed-numbers-are-different-pdf-data-5939", "published_at": "2026-10-24 02:00:00+00:00", "updated_at": "2026-10-24 02:11:05.334673+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["pypdf", "PDF.js", "Mozilla"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/page-positions-page-labels-and-printed-numbers-are-different-pdf-data", "markdown": "https://wpnews.pro/news/page-positions-page-labels-and-printed-numbers-are-different-pdf-data.md", "text": "https://wpnews.pro/news/page-positions-page-labels-and-printed-numbers-are-different-pdf-data.txt", "jsonld": "https://wpnews.pro/news/page-positions-page-labels-and-printed-numbers-are-different-pdf-data.jsonld"}}