Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions tests/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading