Skip to content

fix(api): resolve page boxes as corner pairs, not origin plus size - #88

Open
cloudyjerry wants to merge 1 commit into
LibPDF-js:mainfrom
cloudyjerry:fix/page-geometry-resolution
Open

fix(api): resolve page boxes as corner pairs, not origin plus size#88
cloudyjerry wants to merge 1 commit into
LibPDF-js:mainfrom
cloudyjerry:fix/page-geometry-resolution

Conversation

@cloudyjerry

Copy link
Copy Markdown

Fixes #81

What this fixes

A PDF box entry is [llx lly urx ury] — two opposite corners — but getBox()
returned entries 2 and 3 as width and height. For an origin-zero box that is
accidentally correct, which is why it went unnoticed; for any box with a non-zero
origin the size is too large by exactly the origin.

While measuring that against a reference implementation I found three further
resolution steps in the same code path that are missing or wrong. They are in one
PR because they decide the same number — a caller cannot get a correct page size
while any of them is wrong, and fixing them separately would mean three rounds of
the same tests.

  1. Corner order is no longer trusted. The spec does not require the first
    corner to be the lower-left one, so [512 692 20 30] describes the same
    region as [20 30 512 692]. Before this, a MediaBox written [612 792 0 0]
    gave a page size of 0 x 0.
  2. /MediaBox, /CropBox and /Rotate are now resolved through the page
    tree.
    They are inheritable page attributes (ISO 32000-2, 7.7.3.4) but were
    read from the page dictionary only, so a document carrying them on a /Pages
    node fell back to US Letter at rotation 0. The doc comment on getMediaBox()
    already claimed this worked. The walk stops at the nearest ancestor that
    has the entry rather than the nearest truthy one — an explicit /Rotate 0
    under a rotated parent keeps the page unrotated — and it is depth-capped,
    because /Parent is file-supplied and can be circular in a malformed
    document. BleedBox/TrimBox/ArtBox are not inheritable and still read
    from the page only.
  3. width/height measure the CropBox intersected with the MediaBox. A
    CropBox may crop a page down but never extend it (ISO 32000-2, 14.11.2). The
    previous check picked one box or the other whole, which is wrong whenever the
    two only partly overlap.

The mislabeling also escaped the accessors: embedPage() writes a Form XObject
BBox as [x, y, x + width, y + height], so embedding a page with
/MediaBox [10 25 380 550] produced [10 25 390 575] — a box grown by its own
origin. That is covered by a test here.

Where the expected values come from

They are measured, not derived. Each case was built as a minimal PDF and run
through PDFium (pypdfium2 5.12.1 / pdfium 152.0.7947.0), reading
FPDF_GetPageWidthF/HeightF and cross-checking against the size of the bitmap
it rasterizes. Page size as width x height:

input PDFium 0.4.1 this PR
/MediaBox [0 0 612 792] 612 x 792 612 x 792 612 x 792
/MediaBox [10 25 380 550] 370 x 525 380 x 550 370 x 525
/MediaBox [0 0 612 792] /CropBox [20 30 512 692] 492 x 662 512 x 692 492 x 662
/MediaBox [0 0 612 792] /CropBox [512 692 20 30] 492 x 662 20 x 30 492 x 662
/MediaBox [612 792 0 0] 612 x 792 0 x 0 612 x 792
/MediaBox [0 0 612 792] /CropBox [-10 -10 700 900] 612 x 792 612 x 792 612 x 792
/MediaBox [0 0 612 792] /CropBox [-50 100 400 900] 400 x 692 400 x 900 400 x 692
/MediaBox [0 0 612 792] /CropBox [700 900 800 1000] 0 x 0 612 x 792 612 x 792 †
/MediaBox [0 0 612 792] /CropBox [20 30 512 692] /Rotate 90 662 x 492 692 x 512 662 x 492
/MediaBox [0 0 612 792] /CropBox [20 30 512 692] /Rotate 180 492 x 662 512 x 692 492 x 662
/MediaBox [0 0 612 792] /CropBox [20 30 512 692] /Rotate 270 662 x 492 692 x 512 662 x 492
/MediaBox [0 0 792 612] /Rotate 90 on the parent 612 x 792 612 x 792 ‡ 612 x 792
/Rotate 0 on the page, /Rotate 90 on the parent 612 x 792 612 x 792 612 x 792

The one deliberate divergence. For a CropBox sharing no area with the
MediaBox, PDFium reports a 0 x 0 page and refuses to rasterize it. This reports
the MediaBox instead, on the grounds that a library returning 0 propagates it
into every dimension a caller computes, and there is no correct answer for
degenerate input anyway. Happy to switch to strict PDFium parity if you would
rather — it is one line, and I have no strong claim here beyond "a zero is worse
than a fallback".

‡ Matches by coincidence, not agreement: 0.4.1 arrives at 612 x 792 from the US
Letter fallback at rotation 0, PDFium from an inherited 792 x 612 box turned 90
degrees. getMediaBox() and rotation disagree on that row even though the size
does not, so the test for it asserts the box and the rotation rather than just the
size.

Compatibility

This changes returned values, which is the point, but worth stating plainly:

  • getMediaBox()/getCropBox()/getBleedBox()/getTrimBox()/getArtBox()
    return a different width/height for any box with a non-zero origin or
    unordered corners. Origin-zero boxes with ordered corners — the overwhelming
    majority — are unchanged.
  • page.width/page.height change for those same pages, plus pages that
    inherit their attributes and pages whose CropBox partly overhangs.
  • embedPage() produces a corrected BBox for such pages.

Test plan

bun run test:run, bun run typecheck, bun run lint all pass.

  • Before: 135 files, 2981 passed / 2993
  • After: 136 files, 3031 passed / 3043

50 tests added in a new src/api/pdf-page.test.ts; no existing test changed
status. 32 of the 50 fail against main — the other 18 are origin-zero and
already-correct cases, kept as regression guards so a future refactor cannot
reintroduce the bug from the other direction.

AI assistance disclosure

Per CONTRIBUTING, this contribution was written with AI assistance and here is
the disclosure.

What the AI did. Wrote the implementation in src/api/pdf-page.ts and the
tests in src/api/pdf-page.test.ts, and built the PDFium measurement harness
used to derive the expected values.

The prompts. These are the instructions I gave, in substance. They are
paraphrased rather than pasted verbatim, because the originals referenced the
private codebase where I hit the bug and those details are not relevant to this
repository; nothing technical has been added or removed in the paraphrase.

Fix PDF page-box resolution in this library. A page box is [llx lly urx ury]
— two corners, in either order — and the accessors treat entries 2 and 3 as a
width and a height, which is only right when the origin is zero. Fix the corner
handling, and also resolve the neighbouring gaps in the same path: /MediaBox,
/CropBox and /Rotate are inheritable through the page tree per ISO 32000-2
7.7.3.4 (nearest ancestor that has the entry wins, so an explicit /Rotate 0
beats an inherited 90; cap the walk since /Parent can be circular), and the
CropBox has to be intersected with the MediaBox per 14.11.2 rather than one
being chosen whole. BleedBox/TrimBox/ArtBox are not inheritable.

Do not write the expected values by hand. Build each case as a minimal PDF,
measure what PDFium reports for it via pypdfium2, and let the measurements
drive the test expectations — including any case where the measurement
contradicts what we assumed. Follow the repo's CONTRIBUTING and CODE_STYLE.
Every existing test must keep its current status.

The instruction to measure rather than assume changed two rows: the degenerate
CropBox case (PDFium reports 0 x 0, not the MediaBox fallback we had assumed)
and the inherited case (where the page size matches by coincidence and only the
box and rotation reveal the defect). Both are called out above rather than
smoothed over.

Review. I have read and understand every line of this diff, including the
test expectations and the one place where I chose to diverge from PDFium; I am
submitting it as my own work and can answer for any part of it.

A PDF box entry is `[llx lly urx ury]` — two opposite corners — but `getBox()`
returned entries 2 and 3 as `width` and `height`. For the common origin-zero box
that happens to be right, which is why it went unnoticed; for any box with a
non-zero origin the reported size is too large by exactly the origin. A page with
`/MediaBox [10 25 380 550]` measured 380 x 550 instead of 370 x 525.

The mislabeling also escaped the accessors: `embedPage()` writes a Form XObject
BBox as `[x, y, x + width, y + height]`, so embedding such a page produced a
bounding box of `[10 25 390 575]` — a box grown by its own origin.

Three related resolution gaps are fixed alongside it, because they decide the
same number and a caller cannot get a correct page size while any of them is
wrong:

- Corner order is no longer trusted. The spec does not require the first corner
  to be the lower-left one, and `[512 692 20 30]` describes the same region as
  `[20 30 512 692]`.
- MediaBox, CropBox and Rotate are inheritable page attributes (ISO 32000-2,
  7.7.3.4), but were read from the page dictionary only, so a document that
  carries them on a `/Pages` node fell back to US Letter and no rotation. The
  doc comment on `getMediaBox()` already claimed this worked. The walk stops at
  the nearest ancestor that *has* the entry rather than the nearest truthy one,
  so an explicit `/Rotate 0` under a rotated parent keeps the page unrotated, and
  it is depth-capped because `/Parent` is file-supplied and can be circular.
- `width`/`height` now measure the CropBox intersected with the MediaBox. A
  CropBox may crop a page down but never extend it (ISO 32000-2, 14.11.2); the
  previous check picked one box or the other whole, which is wrong whenever the
  two only partly overlap.

Expected values in the new tests are measured against PDFium rather than derived
by hand: 12 of the 13 cases match it exactly. The 13th is a CropBox sharing no
area with the MediaBox, where PDFium reports a 0 x 0 page; this reports the
MediaBox instead, so that a degenerate box cannot push a zero into every
dimension a caller computes from it.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@cloudyjerry is attempting to deploy a commit to the mythie's projects Team on Vercel.

A member of the Team first needs to authorize it.

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.

getMediaBox()/getCropBox() return raw x2/y2 as width/height for non-zero-origin boxes

1 participant