From d016a94f77022523691d23d7327ef2c02c680743 Mon Sep 17 00:00:00 2001 From: kk1987 Date: Tue, 15 Sep 2026 18:44:01 -0700 Subject: [PATCH] pdftopdf: Honor inherited MediaBox, CropBox and Rotate attributes The PDFio-based cfFilterPDFToPDF() read /MediaBox, /CropBox and /Rotate only from the page dictionary itself. These attributes are inheritable (ISO 32000-1, 7.7.3.4) and producers such as macOS Quartz PDFContext define the MediaBox on the /Pages node only. Such a page fell back to the output page size, so print-scaling and auto-rotation were skipped and an oversized page was printed at 1:1, cropped to its lower-left corner. The QPDF-based code handled this through pushInheritedAttributesToPage(); the PDFio port lost it. pdfioDictGetRect() also rejects an indirect reference to the rectangle array, which is what qpdf writes when it pushes inherited attributes down to the pages, so resolve that case as well. Add page_get_rect() and page_get_rotate() helpers that walk up the /Parent chain and use them for the input page boxes in copy_page() and for the /Rotate lookup in flatten_pdf(). Add a regression test: test_files/inherited_mediabox.pdf is a single 2970x2100 pt page whose MediaBox is an indirect array on the /Pages node. test-pdftopdf-inherited-mediabox.sh runs it through the testfilters harness for Letter and reads the output back with PDFio, checking that the page transform scales by less than 0.5 and rotates by 90 degrees. Fixes #251 Assisted-By: Claude Code --- Makefile.am | 8 +- cupsfilters/pdftopdf.c | 80 ++++++++++- .../test-pdftopdf-inherited-mediabox.c | 127 ++++++++++++++++++ .../test-pdftopdf-inherited-mediabox.sh | 71 ++++++++++ cupsfilters/test_files/inherited_mediabox.pdf | Bin 0 -> 623 bytes 5 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 cupsfilters/test-pdftopdf-inherited-mediabox.c create mode 100755 cupsfilters/test-pdftopdf-inherited-mediabox.sh create mode 100644 cupsfilters/test_files/inherited_mediabox.pdf diff --git a/Makefile.am b/Makefile.am index 8fdf8fc72..f298ba31a 100644 --- a/Makefile.am +++ b/Makefile.am @@ -95,7 +95,8 @@ check_SCRIPTS = \ cupsfilters/testfilters.sh \ cupsfilters/test-pclm-overflow.sh \ cupsfilters/test-pwgtopdf-bit-row.sh \ - cupsfilters/test-pdftoraster-copy-height.sh + cupsfilters/test-pdftoraster-copy-height.sh \ + cupsfilters/test-pdftopdf-inherited-mediabox.sh check_PROGRAMS = \ testcmyk \ @@ -122,7 +123,8 @@ TESTS = \ cupsfilters/testfilters.sh \ cupsfilters/test-pclm-overflow.sh \ cupsfilters/test-pwgtopdf-bit-row.sh \ - cupsfilters/test-pdftoraster-copy-height.sh + cupsfilters/test-pdftoraster-copy-height.sh \ + cupsfilters/test-pdftopdf-inherited-mediabox.sh # testcmyk # fails as it opens some image.ppm which is nowerhe to be found. # testimage # requires also some ppm file as argument @@ -358,6 +360,7 @@ EXTRA_DIST += cupsfilters/gen-lorem-text.c # (not a check_PROGRAM, so it can skip when ASan is absent); named here so it # ships in "make dist". EXTRA_DIST += cupsfilters/test-pdftoraster-copy-height.c +EXTRA_DIST += cupsfilters/test-pdftopdf-inherited-mediabox.c # Generated deterministic lorem text for texttopdf tests BUILT_SOURCES = cupsfilters/test_files/test_text_lorem.txt CLEANFILES = cupsfilters/test_files/test_text_lorem.txt @@ -375,6 +378,7 @@ EXTRA_DIST += \ cupsfilters/test-filter-cases.txt \ cupsfilters/test_files/filled-2.pdf \ cupsfilters/test_files/form_english.pdf \ + cupsfilters/test_files/inherited_mediabox.pdf \ cupsfilters/test_files/malformed.pdf \ cupsfilters/test_files/onepage-a4-adobe-rgb-8-150dpi.pwg \ cupsfilters/test_files/test_file_1pg.pdf \ diff --git a/cupsfilters/pdftopdf.c b/cupsfilters/pdftopdf.c index f5397f479..679edfd54 100644 --- a/cupsfilters/pdftopdf.c +++ b/cupsfilters/pdftopdf.c @@ -250,6 +250,80 @@ media_to_rect(cups_media_t *size, // I - CUPS media (size) information crop->y2 = 72.0 * (size->length - size->top) / 2540.0; } +// +// 'page_get_rect()' - Look up an inheritable rectangle attribute ("MediaBox" +// or "CropBox") for a page. +// +// Page attributes may be inherited from ancestor /Pages nodes (ISO 32000-1 +// section 7.7.3.4), so walk up the /Parent chain. Also resolve an indirect +// reference to the rectangle array, which pdfioDictGetRect() does not do. +// + +static bool // O - `true` if found, `false` otherwise +page_get_rect(pdfio_obj_t *page, // I - Page object + const char *key, // I - Key ("MediaBox" or "CropBox") + pdfio_rect_t *rect) // O - Rectangle +{ + pdfio_obj_t *obj = page; // Current page/pages node + pdfio_dict_t *dict; // Current dictionary + pdfio_obj_t *ref; // Indirect rectangle array object + pdfio_array_t *array; // Rectangle array + int depth; // Loop guard against cyclic /Parent + + + for (depth = 0; obj && depth < 64; depth ++) + { + if ((dict = pdfioObjGetDict(obj)) == NULL) + break; + + if (pdfioDictGetRect(dict, key, rect)) + return (true); + + if ((ref = pdfioDictGetObj(dict, key)) != NULL && (array = pdfioObjGetArray(ref)) != NULL && pdfioArrayGetSize(array) == 4) + { + rect->x1 = pdfioArrayGetNumber(array, 0); + rect->y1 = pdfioArrayGetNumber(array, 1); + rect->x2 = pdfioArrayGetNumber(array, 2); + rect->y2 = pdfioArrayGetNumber(array, 3); + return (true); + } + + obj = pdfioDictGetObj(dict, "Parent"); + } + + memset(rect, 0, sizeof(pdfio_rect_t)); + return (false); +} + + +// +// 'page_get_rotate()' - Look up the inheritable /Rotate attribute for a page, +// walking up the /Parent chain. +// + +static int // O - Rotation in degrees (0 if unset) +page_get_rotate(pdfio_obj_t *page) // I - Page object +{ + pdfio_obj_t *obj = page; // Current page/pages node + pdfio_dict_t *dict; // Current dictionary + int depth; // Loop guard against cyclic /Parent + + + for (depth = 0; obj && depth < 64; depth ++) + { + if ((dict = pdfioObjGetDict(obj)) == NULL) + break; + + if (pdfioDictGetType(dict, "Rotate") == PDFIO_VALTYPE_NUMBER) + return ((int)pdfioDictGetNumber(dict, "Rotate")); + + obj = pdfioDictGetObj(dict, "Parent"); + } + + return (0); +} + + // // 'prepare_log()' - Log an informational or error message while preparing // documents for printing. @@ -1518,7 +1592,7 @@ flatten_pdf(xform_prepare_t *p, // I - Preparation data idict = pdfioObjGetDict(outpage->input[pg]); annotsArray = pdfioDictGetArray(idict, "Annots"); - rotate_val = (int)pdfioDictGetNumber(idict, "Rotate"); + rotate_val = page_get_rotate(outpage->input[pg]); count = pdfioArrayGetSize(annotsArray); p->annotation_contents = (char**)malloc(count * sizeof(char*)); @@ -2044,10 +2118,10 @@ copy_page(xform_prepare_t *p, // I - Preparation data // Transform input page to output cell... idict = pdfioObjGetDict(outpage->input[layout]); - if (!pdfioDictGetRect(idict, "CropBox", &irect)) + if (!page_get_rect(outpage->input[layout], "CropBox", &irect)) { // No crop box, use media box... - if (!pdfioDictGetRect(idict, "MediaBox", &irect)) + if (!page_get_rect(outpage->input[layout], "MediaBox", &irect)) { // No media box, use output page size... irect = p->media; diff --git a/cupsfilters/test-pdftopdf-inherited-mediabox.c b/cupsfilters/test-pdftopdf-inherited-mediabox.c new file mode 100644 index 000000000..4de83969f --- /dev/null +++ b/cupsfilters/test-pdftopdf-inherited-mediabox.c @@ -0,0 +1,127 @@ +// +// Checker for test-pdftopdf-inherited-mediabox.sh: read back the PDF that +// cfFilterPDFToPDF() produced from test_files/inherited_mediabox.pdf and +// inspect the transformation applied to the input page. +// +// The input page is 2970x2100 pt with its MediaBox inherited from the +// /Pages node; printed on Letter with print-scaling=auto it has to be +// scaled down (by about 0.26) and rotated by 90 degrees. When the +// inherited MediaBox is ignored, pdftopdf assumes the input page is already +// Letter-sized and emits a ~0.97 scale without rotation. +// +// Copyright © 2026 by OpenPrinting +// +// Licensed under Apache License v2.0. See the file "LICENSE" for more +// information. +// + +#include +#include +#include +#include +#include + + +// +// 'main()' - Check the first "cm" operator of page 1. +// + +int // O - 0 on success, 1 on failure +main(int argc, // I - Number of command-line arguments + char *argv[]) // I - Command-line arguments +{ + pdfio_file_t *pdf; // Output PDF from pdftopdf + pdfio_obj_t *page; // First page + pdfio_stream_t *st; // Content stream + size_t i, // Looping var + num_streams; // Number of content streams + char token[256], // Current token + *end; // End of numeric token + double value, // Numeric token value + matrix[6]; // Last six numbers seen + int num_values = 0, // Number of numbers in the window + found = 0; // Found a "cm" operator? + double scale; // Scale factor of the matrix + + + if (argc != 2) + { + fputs("Usage: test-pdftopdf-inherited-mediabox OUTPUT.pdf\n", stderr); + return (1); + } + + if ((pdf = pdfioFileOpen(argv[1], NULL, NULL, NULL, NULL)) == NULL) + { + fprintf(stderr, "Unable to open \"%s\".\n", argv[1]); + return (1); + } + + if (pdfioFileGetNumPages(pdf) != 1) + { + fprintf(stderr, "Expected 1 output page, got %u.\n", (unsigned)pdfioFileGetNumPages(pdf)); + return (1); + } + + page = pdfioFileGetPage(pdf, 0); + num_streams = pdfioPageGetNumStreams(page); + + for (i = 0; i < num_streams && !found; i ++) + { + if ((st = pdfioPageOpenStream(page, i, true)) == NULL) + { + fprintf(stderr, "Unable to open content stream %u.\n", (unsigned)i); + return (1); + } + + while (pdfioStreamGetToken(st, token, sizeof(token))) + { + value = strtod(token, &end); + + if (end > token && !*end) + { + // Number: keep the last six... + if (num_values == 6) + memmove(matrix, matrix + 1, 5 * sizeof(double)); + else + num_values ++; + + matrix[num_values - 1] = value; + } + else if (!strcmp(token, "cm") && num_values == 6) + { + found = 1; + break; + } + else + num_values = 0; + } + + pdfioStreamClose(st); + } + + pdfioFileClose(pdf); + + if (!found) + { + fputs("No \"cm\" operator found on page 1.\n", stderr); + return (1); + } + + scale = hypot(matrix[0], matrix[1]); + + printf("cm = [%g %g %g %g %g %g], scale = %g\n", matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5], scale); + + if (scale < 0.1 || scale > 0.5) + { + fputs("Input page was not scaled to fit: inherited MediaBox ignored?\n", stderr); + return (1); + } + + if (fabs(matrix[0]) > 1e-6 || fabs(matrix[3]) > 1e-6) + { + fputs("Input page was not rotated to fit: inherited MediaBox ignored?\n", stderr); + return (1); + } + + return (0); +} diff --git a/cupsfilters/test-pdftopdf-inherited-mediabox.sh b/cupsfilters/test-pdftopdf-inherited-mediabox.sh new file mode 100755 index 000000000..992ab22ad --- /dev/null +++ b/cupsfilters/test-pdftopdf-inherited-mediabox.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Regression test: cfFilterPDFToPDF() must honor inheritable page attributes. +# +# /MediaBox, /CropBox and /Rotate may be defined on an ancestor /Pages node +# instead of on the /Page itself (ISO 32000-1, 7.7.3.4), and the value may +# be an indirect reference. The PDFio-based pdftopdf used to read these +# keys only from the page dictionary, so a page with an inherited MediaBox +# was taken to be the output page size already: no scaling, no +# auto-rotation, and an oversized page came out cropped to its lower-left +# corner. +# +# test_files/inherited_mediabox.pdf is a single 2970x2100 pt page whose +# MediaBox is only defined, as an indirect array, on the /Pages node. We +# run it through pdftopdf for Letter with print-scaling=auto via the +# testfilters harness and then read the output back with PDFio (see +# test-pdftopdf-inherited-mediabox.c): the page transform must scale by +# roughly 0.26 and rotate by 90 degrees. +# +# Like testfilters.sh, this runs from the top-level build directory and +# passes file names relative to it: testfilters copies the case file names +# into 100-byte buffers, so long absolute paths must be avoided. +# +set -euo pipefail + +CC="${CC:-cc}" + +TESTFILTERS="./testfilters" +if [[ ! -x "${TESTFILTERS}" ]]; then + echo "testfilters harness not found at ${TESTFILTERS}" >&2 + exit 99 +fi + +FIXTURE="cupsfilters/test_files/inherited_mediabox.pdf" +CHECKER_SRC="cupsfilters/test-pdftopdf-inherited-mediabox.c" +for f in "${FIXTURE}" "${CHECKER_SRC}"; do + if [[ ! -f "${f}" ]]; then + echo "test file not found: ${f}" >&2 + exit 99 + fi +done + +PKG_CFLAGS="$(pkg-config --cflags pdfio 2>/dev/null || true)" +PKG_LIBS="$(pkg-config --libs pdfio 2>/dev/null || true)" +if [[ -z "${PKG_LIBS}" ]]; then + echo "pkg-config cannot find pdfio; skipping." >&2 + exit 77 +fi + +WORKDIR="$(mktemp -d ./inherited-mediabox.XXXXXX)" +cleanup() { rm -rf "${WORKDIR}"; } +trap cleanup EXIT + +CASES="${WORKDIR}/cases.txt" +OUTPUT="${WORKDIR}/output.pdf" +CHECKER="${WORKDIR}/check" + +# One testfilters case: fixture -> pdftopdf -> Letter PDF. +printf '%s\tapplication/pdf\t%s\tapplication/pdf\tGeneric\tPDF Color 2\t1\t1\tapplication/pdf\t42\tinherited-user\tinherited-mediabox\t1\tmedia-size=letter print-scaling=auto\tpdftopdf\n' \ + "${FIXTURE}" "${OUTPUT}" > "${CASES}" + +"${TESTFILTERS}" "${CASES}" + +if [[ ! -s "${OUTPUT}" ]]; then + echo "pdftopdf produced no output" >&2 + exit 1 +fi + +"${CC}" -std=gnu11 -O0 ${PKG_CFLAGS} "${CHECKER_SRC}" ${PKG_LIBS} -lm -o "${CHECKER}" + +"${CHECKER}" "${OUTPUT}" diff --git a/cupsfilters/test_files/inherited_mediabox.pdf b/cupsfilters/test_files/inherited_mediabox.pdf new file mode 100644 index 0000000000000000000000000000000000000000..df0b4cdf40447f4b320b9f11e1fa504490de0071 GIT binary patch literal 623 zcmZWn%TB{E5WHLBALf!H#LlaYROP^<8~{;LZdDI~x*@1dz;Q+4Cpqv3@FVP|>Dzf} zXJ^Kvjh(?||5Tj?!ukFC{t*fi)zho!_252!zG=7@nayTZ4tJ2{x`s!D6iFgs8UG?)WS`JE%oQ^!~!<1Npcte9!joRnelr4hkRtBQYe UdqQfP8QbIx0x2Bl>b@`j0h802sQ>@~ literal 0 HcmV?d00001