From b4b6b6d2ac32e490f88d40aaed51ae521bf6fbc1 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Mon, 2 Nov 2026 13:05:08 +0100 Subject: [PATCH] TST: Cover content stream renaming, scaling and merging of PageObject Add tests for PageObject branches without coverage so far: * _content_stream_rename() with an inline image: renaming a color space resource called /G renames "/G cs", but keeps /CS /G in the inline image, where it means DeviceGray, * scale() of a page with a /VP viewport, given as array and as dictionary, * extract_text() passing text to visitor_text that is not terminated by an ET operator. Two strict xfail tests document bugs in merge_page(): * #4335: merging into a page whose /Annots is null silently drops the annotations of the merged page, * #3467: merging a page whose annotations were added with PdfWriter.add_annotation() raises TypeError: clone() creates the copy with self.__class__(), but Highlight, FreeText, ... have required keyword-only arguments. The open PRs #3785 and #4262 fix it. Where the expected behavior comes from ISO 32000-2:2020, the docstring names the section. Co-Authored-By: Claude Opus 5.5 --- tests/test_page.py | 120 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/test_page.py b/tests/test_page.py index 46ea020e5b..82225301fa 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -1935,3 +1935,123 @@ def test_translate_value_inline_image_colorspace_not_found(resources): custom_name = NameObject("/CustomCS") with pytest.raises(PdfReadError, match=r"^Cannot find resource entry .+ for .+$"): page._translate_value_inline_image("/CS", custom_name) + + +def test_content_stream_rename__device_color_space_abbreviation_in_inline_image_is_not_renamed() -> None: + """ + Renaming a resource called /G does not change /CS /G in an inline image. + + In an inline image, /G always means DeviceGray and never refers to a + resource in the ColorSpace subdictionary (ISO 32000-2:2020, 8.9.7, NOTE 3). + Renaming it to /G-0 would leave the image with an unknown color space. + """ + content = ContentStream(None, None) + # The same name /G appears twice: + # * "/G cs" is the control. The operand of cs is a resource in the + # ColorSpace subdictionary unless it is DeviceGray, DeviceRGB, DeviceCMYK + # or Pattern (8.6.8, Table 73), and the abbreviation G is only valid in + # inline images (8.9.7). So this /G is the resource and is renamed. + # * In the inline image (BI ... ID ... EI), /G means DeviceGray and is kept. + # The image is 1x1 gray (8.9.7, Table 91 for the abbreviations): one byte + # of data, \x80. + content.set_data(b"/G cs BI /W 1 /H 1 /CS /G /BPC 8 ID \x80 EI") + rename = {NameObject("/G"): NameObject("/G-0")} + + renamed = PageObject._content_stream_rename(content, rename, None) + + (cs_operands, cs_operator), (image_operands, image_operator) = renamed.operations + assert (cs_operands, cs_operator) == ([NameObject("/G-0")], b"cs") + assert image_operator == b"INLINE IMAGE" + assert image_operands["settings"]["/CS"] == "/G" + assert image_operands["data"] == b"\x80" + + +@pytest.mark.parametrize("as_array", [True, False]) +def test_scale__viewport_bounding_box(as_array: bool) -> None: + """ + scale() scales the bounding box of a viewport together with the page. + + The /BBox of a viewport is given in default user space (ISO 32000-2:2020, + 12.9.1, Table 265), so it has to follow the page content. /VP has to be an + array of viewport dictionaries (Table 31); a single dictionary is malformed, + but is scaled as well. + """ + page = PageObject.create_blank_page(None, 100, 100) + viewport = DictionaryObject({NameObject("/BBox"): RectangleObject((0, 0, 10, 20))}) + page[NameObject("/VP")] = ArrayObject([viewport]) if as_array else viewport + + # Different factors for x and y, so swapped axes would be noticed. + page.scale(2, 3) + + scaled_viewport = page["/VP"][0] if as_array else page["/VP"] + assert scaled_viewport["/BBox"] == RectangleObject((0, 0, 20, 60)) + + +def test_extract_text__visitor_receives_text_without_end_operator() -> None: + """ + visitor_text receives the text of a text object that is not closed. + + A text object ends with ET (ISO 32000-2:2020, 9.4.1). Without it, the text + is still pending when the content stream ends, and has to be passed to the + visitor then. + """ + from .test_text_extraction import _page_with_helvetica # noqa: PLC0415 + + # A text object with BT, but without ET. + page = PdfReader(_page_with_helvetica(b"BT /F1 12 Tf (Hello) Tj")).pages[0] + parts = [] + assert page.extract_text(visitor_text=lambda text, *_: parts.append(text)) == "Hello" + assert "".join(parts) == "Hello" + assert parts[-1] == "Hello" + + +@pytest.mark.xfail( + reason="#4335: Annotations are dropped if the target page has a null /Annots entry", strict=True +) +def test_merge_page__target_with_null_annotations() -> None: + """ + merge_page() adds the annotations of the merged page to a target page with + /Annots null. + + A null value is the same as a missing entry (ISO 32000-2:2020, 7.3.9), so + the target page has no annotations yet (/Annots, Table 31). After the merge, + it has an /Annots array with the merged annotation. + """ + source = PdfWriter() + source.add_blank_page(100, 100) + # ISO 32000-2:2020, Table 182: the four vertices in counterclockwise order. + quad_points = ArrayObject(FloatObject(value) for value in (10, 10, 20, 10, 20, 20, 10, 20)) + source.add_annotation(0, Highlight(rect=(10, 10, 20, 20), quad_points=quad_points)) + # Write and read the source, so the annotation is a plain dictionary and #3467 does not interfere. + source_data = BytesIO() + source.write(source_data) + writer = PdfWriter() + target = writer.add_blank_page(100, 100) + target[NameObject("/Annots")] = NullObject() + + target.merge_page(PdfReader(source_data).pages[0]) + + assert len(target["/Annots"]) == 1 + + +@pytest.mark.xfail( + reason="#3467: Cloning annotations created with pypdf.annotations fails", strict=True +) +def test_merge_page__annotation_added_by_writer() -> None: + """ + merge_page() copies an annotation that was added with + PdfWriter.add_annotation() and not yet written. + + The writer keeps the Highlight object itself, whose constructor requires + arguments, so cloning it with self.__class__() fails. + """ + source = PdfWriter() + source.add_blank_page(100, 100) + # ISO 32000-2:2020, Table 182: the four vertices in counterclockwise order. + quad_points = ArrayObject(FloatObject(value) for value in (10, 10, 20, 10, 20, 20, 10, 20)) + source.add_annotation(0, Highlight(rect=(10, 10, 20, 20), quad_points=quad_points)) + target = PdfWriter().add_blank_page(100, 100) + + target.merge_page(source.pages[0]) + + assert len(target["/Annots"]) == 1