feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)

This commit is contained in:
Antigravity committed 2026-10-06 10:16:40 +03:00
1 parent c38b1dae0a
commit 6dcfd59f73
67 files changed
+49444 -40

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
# Copyright (c) 2006, Mathieu Fenniak
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""Implementation of generic PDF objects (dictionary, number, string, ...)."""
__author__ = "Mathieu Fenniak"
__author_email__ = "biziqe@mathieu.fenniak.net"
from ..constants import OutlineFontFlag
from ._base import (
BooleanObject,
ByteStringObject,
FloatObject,
IndirectObject,
NameObject,
NullObject,
NumberObject,
PdfObject,
TextStringObject,
encode_pdfdocencoding,
is_null_or_none,
)
from ._data_structures import (
ArrayObject,
ContentStream,
DecodedStreamObject,
Destination,
DictionaryObject,
EncodedStreamObject,
Field,
StreamObject,
TreeObject,
read_object,
)
from ._files import EmbeddedFile
from ._fit import Fit
from ._link import DirectReferenceLink, NamedReferenceLink, ReferenceLink, extract_links
from ._outline import OutlineItem
from ._rectangle import RectangleObject
from ._utils import (
create_string_object,
decode_pdfdocencoding,
hex_to_rgb,
read_hex_string_from_stream,
read_string_from_stream,
)
from ._viewerpref import ViewerPreferences
PAGE_FIT = Fit.fit()
__all__ = [
"PAGE_FIT",
"ArrayObject",
"BooleanObject",
"ByteStringObject",
"ContentStream",
"DecodedStreamObject",
"Destination",
"DictionaryObject",
"DirectReferenceLink",
"EmbeddedFile",
"EncodedStreamObject",
"Field",
"Fit",
"FloatObject",
"IndirectObject",
"NameObject",
"NamedReferenceLink",
"NullObject",
"NumberObject",
"OutlineFontFlag",
"OutlineItem",
"PdfObject",
"RectangleObject",
"ReferenceLink",
"StreamObject",
"TextStringObject",
"TreeObject",
"ViewerPreferences",
# Utility functions
"create_string_object",
"decode_pdfdocencoding",
"encode_pdfdocencoding",
"extract_links",
"hex_to_rgb",
"is_null_or_none",
"read_hex_string_from_stream",
# Data structures core functions
"read_object",
"read_string_from_stream",
]
+894
View File
@@ -0,0 +1,894 @@
from __future__ import annotations
import copy
import re
from dataclasses import dataclass, field
from enum import IntEnum
from io import BytesIO
from operator import attrgetter
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from .._codecs import encoding_dict_from_named_encoding
from .._codecs.core_font_metrics import CORE_FONT_METRICS
from .._font import Font
from .._page import Transformation
from .._utils import is_char_rtl, logger_warning
from ..constants import AnnotationDictionaryAttributes, BorderStyles, FieldDictionaryAttributes, PageAttributes
from ..errors import PdfReadError
from ..generic import (
ArrayObject,
DecodedStreamObject,
DictionaryObject,
FloatObject,
IndirectObject,
NameObject,
NumberObject,
RectangleObject,
StreamObject,
)
from ..generic._base import ByteStringObject, TextStringObject
from ._color import Color, DeviceGray
if TYPE_CHECKING:
from pypdf._writer import PdfWriter
from .._page import PageObject
try:
import arabic_reshaper
from bidi.algorithm import get_display
HAS_RTL_SUPPORT = True
except ImportError:
HAS_RTL_SUPPORT = False
DEFAULT_FONT_SIZE_IN_MULTILINE = 12
# "The glyph widths shall be measured in units in which 1000 units correspond to 1 unit in text space"
# (Table 111, PDF Specification 2.0)
TEXT_SPACE_TO_GLYPH_SPACE_FACTOR = 1000
@dataclass
class BaseStreamConfig:
"""A container representing the basic layout of an appearance stream."""
rectangle: RectangleObject = field(default_factory=lambda: RectangleObject((0.0, 0.0, 0.0, 0.0)))
border_width: int = 1 # The width of the border in points
border_style: str = BorderStyles.SOLID
border_color: Color | None = None
background_color: Color | None = None
rotation: int = 0
class BaseStreamAppearance(DecodedStreamObject):
"""A class representing the very base of an appearance stream, that is, a rectangle and a border."""
def _add_matrix(self, rotation: int) -> None:
# We need to rotate our rectangle while keeping its origin to (0, 0).
# Rotation goes counterclockwise. We want to know the furthest points to which we rotated left and down.
# These will serve as our X and Y offsets to translate the entire object origin back to (0, 0).
# If a corner rotates into negative space, that is our offset. If none does, the minimum is 0.0.
matrix = Transformation().rotate(rotation)
bottom_right_corner = (self._layout.rectangle.width, 0.0)
top_left_corner = (0.0, self._layout.rectangle.height)
top_right_corner = (self._layout.rectangle.width, self._layout.rectangle.height)
rotated_bottom_right_corner = matrix.apply_on(bottom_right_corner)
rotated_top_left_corner = matrix.apply_on(top_left_corner)
rotated_top_right_corner = matrix.apply_on(top_right_corner)
translation_x_offset = -min(
0.0, rotated_bottom_right_corner[0], rotated_top_left_corner[0], rotated_top_right_corner[0]
)
translation_y_offset = -min(
0.0, rotated_bottom_right_corner[1], rotated_top_left_corner[1], rotated_top_right_corner[1]
)
matrix = matrix.translate(translation_x_offset, translation_y_offset)
self[NameObject("/Matrix")] = ArrayObject([FloatObject(round(i, 3)) for i in matrix.ctm])
def __init__(self, layout: BaseStreamConfig | None) -> None:
"""
Takes the appearance stream layout as an argument.
Args:
layout: The basic layout parameters.
"""
super().__init__()
self._layout = layout or BaseStreamConfig()
self[NameObject("/Type")] = NameObject("/XObject")
self[NameObject("/Subtype")] = NameObject("/Form")
self[NameObject("/BBox")] = self._layout.rectangle
# Define the rotation matrix
rotation = self._layout.rotation % 360
if rotation:
self._add_matrix(rotation)
ap_stream_parts = []
# Only add a background color when color is defined and not transparent
if isinstance(self._layout.background_color, Color):
ap_stream_parts.append(
f"0 0 {self._layout.rectangle.width} {self._layout.rectangle.height} re\n"
f"{self._layout.background_color.as_operator()}\n"
"f\n"
)
# Only add a border when border width is larger than 0, border
# color is defined, and border color is not set to transparent.
if self._layout.border_width > 0:
if isinstance(self._layout.border_color, Color):
border_width_string = f"{self._layout.border_width} w\n" if self._layout.border_width != 1 else ""
ap_stream_parts.append(
f"{self._layout.border_width} "
f"{self._layout.border_width} "
f"{self._layout.rectangle.width - 2 * self._layout.border_width} "
f"{self._layout.rectangle.height - 2 * self._layout.border_width} re\n"
f"{border_width_string}"
f"{self._layout.border_color.as_operator(stroke=True)}\n"
"s\n"
)
else:
# If we aren't drawing a border, then set border_width to 0. This means less margin to
# take into account and results in larger font sizes.
self._layout.border_width = 0
if ap_stream_parts:
ap_stream_parts.insert(0, "q\n")
ap_stream_parts.append("Q\n")
self._ap_stream_data = "".join(ap_stream_parts).encode()
class TextAlignment(IntEnum):
"""Defines the alignment options for text within a form field's appearance stream."""
LEFT = 0
CENTER = 1
RIGHT = 2
class WidthWordGlyphs(NamedTuple):
"""A tuple of the unscaled width of a word (unencoded text) and the font-encoded glyphs that represent it."""
width: float
word: str
glyphs: str
class TextStreamAppearance(BaseStreamAppearance):
"""
A class representing the appearance stream for a text-based form field.
This class generates the content stream (the `ap_stream_data`) that dictates
how text is rendered within a form field's bounding box. It handles properties
like font, font size, color, multiline text, and text selection highlighting.
"""
def _scale_text(
self,
font: Font,
font_size: float,
leading_factor: float,
field_width: float,
field_height: float,
paragraphs: list[list[WidthWordGlyphs]],
min_font_size: float,
font_size_step: float = 0.2
) -> tuple[list[WidthWordGlyphs], float]:
"""
Takes a piece of text and scales it to field_width or field_height, given font_name
and font_size. Wraps text where necessary.
Args:
font: The font to be used.
font_size: The font size in points.
leading_factor: The line distance.
field_width: The width of the field in which to fit the text.
field_height: The height of the field in which to fit the text.
paragraphs: The list of text paragraphs to fit with the field, where each paragraph is a list
of tuples of unscaled width, unencoded text, and glyphs (font-encoded text).
min_font_size: The minimum font size at which to scale the text.
font_size_step: The amount by which to decrement font size per step while scaling.
Returns:
The text in the form of list of tuples, each tuple containing the length of a line
and its contents, and the font_size for these lines and lengths.
"""
wrapped_lines = []
current_line_words: list[WidthWordGlyphs] = []
current_line_width: float = 0
space_width = font.space_width * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
for paragraph in paragraphs:
for i, width_word_glyphs in enumerate(paragraph):
word_width = width_word_glyphs.width * font_size
test_width = current_line_width + word_width + (space_width if i else 0)
if test_width > field_width and current_line_words:
wrapped_lines.append(
WidthWordGlyphs(
width=current_line_width,
word=" ".join(map(attrgetter("word"), current_line_words)),
glyphs=font.space_char.join(map(attrgetter("glyphs"), current_line_words))
)
)
current_line_words = [width_word_glyphs]
current_line_width = word_width
elif not current_line_words and word_width > field_width:
wrapped_lines.append(
WidthWordGlyphs(
width=word_width,
word=width_word_glyphs.word,
glyphs=width_word_glyphs.glyphs
)
)
current_line_words = []
current_line_width = 0
else:
if current_line_words:
current_line_width += space_width
current_line_words.append(width_word_glyphs)
current_line_width += word_width
if current_line_words:
wrapped_lines.append(WidthWordGlyphs(
width=current_line_width,
word=" ".join(map(attrgetter("word"), current_line_words)),
glyphs=font.space_char.join(map(attrgetter("glyphs"), current_line_words))
))
current_line_words = []
current_line_width = 0
# Estimate total height.
estimated_total_height = font_size + (len(wrapped_lines) - 1) * leading_factor * font_size
if estimated_total_height > field_height:
# Text overflows height; Retry with smaller font size.
new_font_size = font_size - font_size_step
if new_font_size >= min_font_size:
return self._scale_text(
font,
new_font_size,
leading_factor,
field_width,
field_height,
paragraphs,
min_font_size,
font_size_step
)
return wrapped_lines, round(font_size, 1)
def _generate_appearance_stream_data(
self,
text: str,
selection: list[str] | None ,
font: Font,
font_name: str = "/Helv",
font_size: float = 0.0,
font_color: Color | None = None,
is_multiline: bool = False,
alignment: TextAlignment = TextAlignment.LEFT,
is_comb: bool = False,
max_length: int | None = None
) -> bytes:
"""
Generates the raw bytes of the PDF appearance stream for a text field.
This private method assembles the PDF content stream operators to draw
the provided text within the specified rectangle. It handles text positioning,
font application, color, and special formatting like selected text.
Args:
text: The text to be rendered in the form field.
selection: An optional list of strings that should be highlighted as selected.
font: The font to use.
font_name: The name of the font resource to use (e.g., "/Helv").
font_size: The font size. If 0, it is automatically calculated
based on whether the field is multiline or not.
font_color: The color to apply to the font, represented as a class Color
is_multiline: A boolean indicating if the text field is multiline.
alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.
is_comb: Boolean that designates fixed-length fields, where every character
fills one "cell", such as in a postcode.
max_length: Used if is_comb is set. The maximum number of characters for a fixed-
length field.
Returns:
A byte string containing the PDF content stream data.
"""
font_color = font_color or DeviceGray()
rectangle = self._layout.rectangle
leading_factor = (
(font.font_descriptor.bbox[3] - font.font_descriptor.bbox[1]) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
)
# Set margins based on border width and style
factor = 2 if self._layout.border_style in {"/B", "/I"} else 1
margin = self._layout.border_width * factor
field_height = rectangle.height - 2 * margin
field_width = rectangle.width - 4 * max(margin, 1)
reverse_cmap, encoding_cmap = font._get_typographic_maps()
def _unicode_to_glyph_id(text: str, reverse_cmap: dict[str, str]) -> str:
if HAS_RTL_SUPPORT:
# Use arabic-reshaper and python-bidi to rearrange and shape text for the PDF engine
reshaped_text = arabic_reshaper.reshape(text)
visual_text = get_display(reshaped_text, base_dir="L")
return "".join(reverse_cmap.get(char, char) for char in visual_text)
return "".join(reverse_cmap.get(char, char) for char in text)
def _glyph_id_to_bytes(glyphs: str, encoding_cmap: dict[str, bytes]) -> list[bytes]:
return [encoding_cmap.get(
glyph_id, bytes((ord(glyph_id),)) if ord(glyph_id) < 256 else b"?"
) for glyph_id in glyphs]
# If font_size is 0, apply the logic for multiline or large-as-possible font
if font_size == 0:
min_font_size = 4.0 # The minimum font size
if selection: # Don't wrap text when dealing with a /Ch field, in order to prevent problems
is_multiline = False # with matching "selection" with "line" later on.
if is_multiline:
font_size = DEFAULT_FONT_SIZE_IN_MULTILINE
# We create a list of paragraphs, here each paragraph is a list of tuples, where each WidthWordGlyphs
# tuple signifies an unscaled word width, the word itself, and the glyphs that encode it.
paragraphs: list[list[WidthWordGlyphs]] = []
for line in text.splitlines():
if not line.strip():
paragraphs.append([WidthWordGlyphs(width=0.0, word="", glyphs="")])
continue
line_by_widths_words_glyphs: list[WidthWordGlyphs] = []
words = line.split(" ")
for word in words:
glyph_word = _unicode_to_glyph_id(word, reverse_cmap)
line_by_widths_words_glyphs.append(
WidthWordGlyphs(
width=font.get_text_width(word) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR,
word=word,
glyphs=glyph_word
)
)
paragraphs.append(line_by_widths_words_glyphs)
lines, font_size = self._scale_text(
font,
font_size,
leading_factor,
field_width,
field_height,
paragraphs,
min_font_size
)
else:
max_vertical_size = field_height / leading_factor
glyphs = _unicode_to_glyph_id(text, reverse_cmap)
text_width_unscaled = font.get_text_width(glyphs) / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
max_horizontal_size = field_width / (text_width_unscaled or 1)
font_size = round(max(min(max_vertical_size, max_horizontal_size), min_font_size), 1)
lines = [WidthWordGlyphs(width=text_width_unscaled * font_size, word=text, glyphs=glyphs)]
elif is_comb:
if max_length and len(text) > max_length:
logger_warning(
(
"Length of text %(text)s exceeds maximum length (%(max_length)d) "
"of field, input truncated."
),
source=__name__,
text=text,
max_length=max_length,
)
# We act as if each character is one line, because we draw it separately later on
lines = []
for index, char in enumerate(text):
if index < (max_length or len(text)):
glyphs = _unicode_to_glyph_id(char, reverse_cmap)
lines.append(
WidthWordGlyphs(
width=font.get_text_width(glyphs) * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR,
word=char,
glyphs=glyphs
)
)
else:
lines = []
for line in text.splitlines():
glyphs = _unicode_to_glyph_id(line, reverse_cmap)
lines.append(
WidthWordGlyphs(
width=font.get_text_width(glyphs) * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR,
word=line,
glyphs=glyphs
)
)
# Set the vertical offset
if is_multiline:
y_offset = (
field_height + margin - font.font_descriptor.bbox[3] * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR
)
else:
y_offset = margin + (
(field_height - font.font_descriptor.ascent * font_size / TEXT_SPACE_TO_GLYPH_SPACE_FACTOR) / 2
)
default_appearance = f"{font_name} {font_size} Tf {font_color.as_operator()}"
ap_stream = bytearray()
ap_stream += (
f"q\n/Tx BMC \nq\n"
f"{2 * max(margin, 1)} {margin} "
f"{round(field_width - 2 * max(margin, 1), 3)} {round(field_height - margin, 3)} re\n"
f"W\nBT\n{default_appearance}\n"
).encode()
current_x_pos: float = 0 # Initial virtual position within the text object.
selection_glyphs = _unicode_to_glyph_id("".join(selection), reverse_cmap) if selection else ""
for line_number, (line_width, original_text, line) in enumerate(lines):
if selection_glyphs and line in selection_glyphs:
# Might be improved, but cannot find how to get fill working => replaced with lined box
ap_stream += (
f"1 {round(y_offset - (line_number * font_size * leading_factor) - 1, 3)} "
f"{rectangle.width - 2} {font_size + 2} re\n"
f"0.5 0.5 0.5 rg s\n{default_appearance}\n"
).encode()
# Calculate the desired absolute starting X for the current line
desired_abs_x_start: float = 0
if is_comb and max_length:
# Calculate the width of a cell for one character
cell_width = rectangle.width / max_length
# Space from the left edge of the cell to the character's baseline start
# line_width here is the *actual* character width in points for the single character 'line'
centering_offset_in_cell = (cell_width - line_width) / 2
# Absolute start X = (Cell Index, i.e., line_number * Cell Width) + Centering Offset
desired_abs_x_start = (line_number * cell_width) + centering_offset_in_cell
elif alignment == TextAlignment.RIGHT:
desired_abs_x_start = rectangle.width - max(margin, 1) * 2 - line_width
elif alignment == TextAlignment.CENTER:
desired_abs_x_start = (rectangle.width - line_width) / 2
else: # Left aligned; default
desired_abs_x_start = max(margin, 1) * 2
# Calculate x_rel_offset: how much to move from the current_x_pos
# to reach the desired_abs_x_start.
x_rel_offset = desired_abs_x_start - current_x_pos
# Y-offset:
y_rel_offset: float = 0
if line_number == 0:
y_rel_offset = y_offset # Initial vertical position
elif is_comb:
y_rel_offset = 0.0 # DO NOT move vertically for subsequent characters
else:
y_rel_offset = - font_size * leading_factor # Move down by line height
# Td is a relative translation (Tx and Ty).
# It updates the current text position.
ap_stream += f"{round(x_rel_offset, 3)} {round(y_rel_offset, 3)} Td\n".encode()
# Update current_x_pos based on the Td operation for the next iteration.
# This is the X position where the *current line* will start.
current_x_pos = desired_abs_x_start
is_rtl = any(is_char_rtl(char) for char in line)
encoded_line = _glyph_id_to_bytes(line, encoding_cmap)
if is_rtl:
# Encode input text as UTF-16BE with a BOM, so that the input text is returned
# in the right direction when copying text from the resulting PDF
bom_text = b"\xfe\xff" + original_text.encode("utf-16-be")
hex_original_text = bom_text.hex().upper()
ap_stream += f"/Span << /ActualText <{hex_original_text}> >> BDC\n".encode()
if font.sub_type == "Type0": # 16-bit font
ap_stream += b"<" + (b"".join(encoded_line)).hex().encode() + b"> Tj\n"
else: # Simple font, 8-bit encoded.
# Escape parentheses (PDF 1.7 reference, table 3.2, Literal Strings)
line_as_bytes = (
b"".join(encoded_line)
.replace(b"\\", b"\\\\")
.replace(b"(", br"\(")
.replace(b")", br"\)")
)
ap_stream += b"(" + line_as_bytes + b") Tj\n"
if is_rtl:
ap_stream += b"EMC\n"
ap_stream += b"ET\nQ\nEMC\nQ\n"
return bytes(ap_stream)
def __init__(
self,
layout: BaseStreamConfig | None = None,
text: str = "",
selection: list[str] | None = None,
font: Font | None = None,
font_resource: DictionaryObject | IndirectObject | None = None,
font_name: str = "/Helv",
font_size: float = 0.0,
font_color: Color | None = None,
is_multiline: bool = False,
alignment: TextAlignment = TextAlignment.LEFT,
is_comb: bool = False,
max_length: int | None = None
) -> None:
"""
Initializes a TextStreamAppearance object.
This constructor creates a new PDF stream object configured as an XObject
of subtype Form. It uses the `_generate_appearance_stream_data` method to
generate the content for the stream.
Args:
layout: The basic layout parameters.
text: The text to be rendered in the form field.
selection: An optional list of strings that should be highlighted as selected.
font: A Font object. Falls back to Type 1 Helvetica if not given.
font_resource: An optional variable that represents a PDF font dictionary. Falls back
to Type 1 Helvetica if not given.
font_name: The name of the font resource, e.g., "/Helv".
font_size: The font size. If 0, it's auto-calculated.
font_color: The font color string.
is_multiline: A boolean indicating if the text field is multiline.
alignment: Text alignment, can be TextAlignment.LEFT, .RIGHT, or .CENTER.
is_comb: Boolean that designates fixed-length fields, where every character
fills one "cell", such as in a postcode.
max_length: Used if is_comb is set. The maximum number of characters for a fixed-
length field.
"""
super().__init__(layout)
if not font or not font_resource:
font_name = "/Helv"
font = Font.from_core_font_name()
font_resource = font.as_font_resource()
self._ap_stream_data += self._generate_appearance_stream_data(
text,
selection,
font,
font_name=font_name,
font_size=font_size,
font_color=font_color or DeviceGray(),
is_multiline=is_multiline,
alignment=alignment,
is_comb=is_comb,
max_length=max_length
)
self.set_data(ByteStringObject(self._ap_stream_data))
self[NameObject("/Length")] = NumberObject(len(self._ap_stream_data))
# Update Resources with font information
self[NameObject("/Resources")] = DictionaryObject({
NameObject("/Font"): DictionaryObject({
NameObject(font_name): getattr(font_resource, "indirect_reference", font_resource)
})
})
@staticmethod
def _find_annotation_font_resource(
font_name: str,
annotation: DictionaryObject,
acro_form: DictionaryObject,
text: str
) -> tuple[str, Font]:
# Try to find a resource dictionary for the font by examining the annotation and, if that fails,
# the AcroForm resources dictionary
acro_form_resources: Any = cast(
DictionaryObject,
annotation.get_inherited(
"/DR",
acro_form.get("/DR", DictionaryObject()),
),
)
acro_form_font_resources = acro_form_resources.get("/Font", DictionaryObject())
font_resource = acro_form_font_resources.get(font_name, None)
if font_resource:
font = Font.from_font_resource(font_resource.get_object())
else:
# Normally, we should have found a font resource by now. However, when a user has provided a specific
# font name, we may not have found the associated font resource among the AcroForm resources. Also, in
# case of the 14 Adobe Core fonts, we may be expected to construct a font resource ourselves.
if font_name.removeprefix("/") not in CORE_FONT_METRICS:
# Default to Helvetica if we haven't found a font resource and cannot construct one ourselves.
logger_warning(
"Font dictionary for %(font_name)s not found; defaulting to Helvetica.",
source=__name__,
font_name=font_name,
)
font_name = "/Helvetica"
font = Font.from_core_font_name(font_name)
# If we have found a font resource, it still might not be able to encode the text value we received.
encodable = font.can_encode(text)
if not encodable:
# If we have a font file, we can try to produce a new font resource with an encoding
# that does include the necessary characters. We only try this for a TrueType font, meaning
# that, in PDF terms, it is a simple, 8-bit encoded font.
if font.font_descriptor.font_file and font.sub_type == "TrueType":
try:
font = font.from_truetype_font_file(BytesIO(font.font_descriptor.font_file.get_data()))
font_name = "/PYPDF1" # This means we most probably do not clash with an existing font name
encodable = font.can_encode(text)
except (ImportError, PdfReadError) as e:
logger_warning("Unable to use embedded font for encoding: %(e)s", source=__name__, e=e)
# If it's one of the unembedded 14 Adobe Core Fonts, we can test other supported encodings
elif font.sub_type == "Type1" and font.name in CORE_FONT_METRICS:
core_font_metrics = CORE_FONT_METRICS[font.name]
test_encodings = {
"cp1250", # Central / Eastern European
"cp1252", # Western European
"cp1254", # Turkish
"cp1257", # Baltic Rim
"iso8859_15" # Western European ISO Alternate
}
for encoding in test_encodings:
test_font = copy.copy(font)
test_font.encoding = encoding_dict_from_named_encoding(encoding)
encodable = test_font.can_encode(text)
if encodable:
font = test_font
font.character_widths.clear()
for code, character in test_font.encoding.items():
# Look up the width using the glyph name from the encoding
if character in core_font_metrics.character_widths:
font.character_widths[chr(code)] = core_font_metrics.character_widths[character]
font.character_widths["default"] = core_font_metrics.character_widths["default"]
font_name = "/PYPDF1" + encoding
break
if not encodable:
logger_warning(
(
"Text string '%(text)s' contains characters not supported by font encoding. "
"This may result in text corruption. "
"Consider calling writer.update_page_form_field_values with auto_regenerate=True."
),
source=__name__,
text=text,
)
return font_name, font
@staticmethod
def _sync_appearance_stream_font_resources(
writer: PdfWriter,
font_name: str,
font: Font,
target_resource_dict: DictionaryObject,
page: PageObject | None = None
) -> IndirectObject:
"""
Unified helper to sync fonts from an AP stream to a target resource dictionary (e.g., AcroForm /DR).
Will sync to page resources as well when page is added to the arguments.
"""
target_fonts = target_resource_dict.setdefault(NameObject("/Font"), DictionaryObject()).get_object()
if font_name not in target_fonts:
font_resource_reference = font._add_to_writer(
writer,
target_fonts,
NameObject(font_name)
)
else:
font_resource_reference = target_fonts[font_name]
if page:
page_fonts_resource = cast(DictionaryObject, page[PageAttributes.RESOURCES]).setdefault(
NameObject("/Font"), DictionaryObject()
).get_object()
if font_name not in page_fonts_resource:
page_fonts_resource[NameObject(font_name)] = getattr(
font_resource_reference, "indirect_reference", font_resource_reference
)
return font_resource_reference
@classmethod
def from_text_annotation(
cls,
writer: PdfWriter,
page: PageObject,
flatten: bool,
acro_form: DictionaryObject, # _root_object[CatalogAttributes.ACRO_FORM])
field: DictionaryObject,
annotation: DictionaryObject,
user_font_name: str = "",
user_font_size: float = -1,
) -> TextStreamAppearance:
"""
Creates a TextStreamAppearance object from a text field annotation.
This class method is a factory for creating a `TextStreamAppearance`
instance by extracting all necessary information (bounding box, font,
text content, etc.) from the PDF field and annotation dictionaries.
It respects inheritance for properties like default appearance (`/DA`).
Args:
writer: The PdfWriter instance that we are creating text stream appearances for.
page: The page that we are processing annotations for.
flatten: Whether we flatten text annotations or not. If true, add new font resource
to the page font resources. Otherwise, add them to the AcroForm resources.
acro_form: The root AcroForm dictionary from the PDF catalog.
field: The field dictionary object.
annotation: The widget annotation dictionary object associated with the field.
user_font_name: An optional user-provided font name to override the
default. Defaults to an empty string.
user_font_size: An optional user-provided font size to override the
default. A value of -1 indicates no override.
Returns:
A new `TextStreamAppearance` instance configured for the given field.
"""
# Calculate rectangle dimensions
_rectangle = cast(RectangleObject, annotation[AnnotationDictionaryAttributes.Rect])
# Normalize the rectangle, apply page rotation if applicable
if page.get_inherited("/Rotate") in {90, 270}:
rectangle = RectangleObject((0, 0, abs(_rectangle[3] - _rectangle[1]), abs(_rectangle[2] - _rectangle[0])))
else:
rectangle = RectangleObject((0, 0, abs(_rectangle[2] - _rectangle[0]), abs(_rectangle[3] - _rectangle[1])))
# Get default appearance dictionary from annotation
default_appearance = annotation.get_inherited(
AnnotationDictionaryAttributes.DA,
acro_form.get(AnnotationDictionaryAttributes.DA, None),
)
if not default_appearance:
# Create a default appearance if none was found in the annotation
default_appearance = TextStringObject("/Helv 0 Tf 0 g")
else:
default_appearance = default_appearance.get_object()
# Retrieve field text and selected values
field_flags = field.get(FieldDictionaryAttributes.Ff, 0)
if (
field.get(FieldDictionaryAttributes.FT, "/Tx") == "/Ch" and
field_flags & FieldDictionaryAttributes.FfBits.Combo == 0
):
text = "\n".join(annotation.get_inherited(FieldDictionaryAttributes.Opt, []))
selection = field.get("/V", [])
if not isinstance(selection, list):
selection = [selection]
else: # /Tx
text = field.get("/V", "")
selection = []
# Derive font name, size and color from the default appearance. Also set
# user-provided font name and font size in the default appearance, if given.
# For a font name, this presumes that we can find an associated font resource
# dictionary. Uses the variable font_properties as an intermediate.
# As per the PDF spec:
# "At a minimum, the string [that is, default_appearance] shall include a Tf (text
# font) operator along with its two operands, font and size" (Section 12.7.4.3
# "Variable text" of the PDF 2.0 specification).
font_properties = [prop for prop in re.split(r"\s", default_appearance) if prop]
da_font_name = font_properties.pop(font_properties.index("Tf") - 2)
font_size = float(font_properties.pop(font_properties.index("Tf") - 1))
font_properties.remove("Tf")
font_color = Color.from_normalized_values(tuple(float(val) for val in font_properties[:-1]))
# Determine the font name to use, prioritizing the user's input
if user_font_name:
font_name = user_font_name
else:
font_name = da_font_name
# Determine the font size to use, prioritizing the user's input
if user_font_size > 0:
font_size = user_font_size
font_name, font = cls._find_annotation_font_resource(font_name, annotation, acro_form, text)
# Change the /DA information if we changed the font name
if font_name != da_font_name:
annotation[NameObject("/DA")] = TextStringObject(default_appearance.replace(da_font_name, font_name))
# Synchronise font resources
font_resource_reference = cls._sync_appearance_stream_font_resources(
writer,
font_name,
font,
acro_form.setdefault(NameObject("/DR"), DictionaryObject()),
page if flatten else None,
)
# Retrieve formatting information
is_comb = False
max_length = None
if field_flags & FieldDictionaryAttributes.FfBits.Comb:
is_comb = True
max_length = annotation.get("/MaxLen")
is_multiline = False
if field_flags & FieldDictionaryAttributes.FfBits.Multiline:
is_multiline = True
alignment = field.get("/Q", TextAlignment.LEFT)
border_width = 1
border_style = BorderStyles.SOLID
if "/BS" in field:
border_width = cast(DictionaryObject, field["/BS"]).get("/W", border_width)
border_style = cast(DictionaryObject, field["/BS"]).get("/S", border_style)
rotation = 0
border_color: Color | None = None
background_color: Color | None = None
appearance_characteristics = field.get_inherited("/MK", None)
if isinstance(appearance_characteristics, DictionaryObject):
rotation = int(appearance_characteristics.get("/R", 0))
# Color.from_normalized_values([]) results in None for a "/BC" or "/BG" value of []
border_color = Color.from_normalized_values(appearance_characteristics.get("/BC"))
background_color = Color.from_normalized_values(appearance_characteristics.get("/BG"))
# Create the TextStreamAppearance instance
layout = BaseStreamConfig(
rectangle=rectangle,
border_width=border_width,
border_style=border_style,
border_color=border_color,
background_color=background_color,
rotation=rotation
)
new_appearance_stream = cls(
layout,
text,
selection,
font,
font_resource_reference,
font_name=font_name,
font_size=font_size,
font_color=font_color,
is_multiline=is_multiline,
alignment=alignment,
is_comb=is_comb,
max_length=max_length
)
if AnnotationDictionaryAttributes.AP in annotation:
for key, value in (
cast(DictionaryObject, annotation[AnnotationDictionaryAttributes.AP]).get("/N", {}).items()
):
if key in {"/BBox", "/Length", "/Subtype", "/Type", "/Filter"}:
continue
# Don't overwrite font resources added by TextAppearanceStream.__init__
if key == "/Resources":
if "/Font" not in value:
value.get_object()[NameObject("/Font")] = DictionaryObject()
value["/Font"].get_object()[NameObject(font_name)] = getattr(
font_resource_reference, "indirect_reference", font_resource_reference
)
else:
new_appearance_stream[key] = value
return new_appearance_stream
def transform_annotation_appearance(annotation_obj: DictionaryObject, transformation: Transformation) -> None:
"""
Compose `transformation` into an annotation's /AP /N appearance stream(s), in place.
Repositioning/resizing an annotation's /Rect alone is not enough: per
the appearance-stream algorithm (PDF 2.0, 12.5.5), a viewer fits the
appearance's /BBox (as mapped by its own /Matrix) into /Rect using an
axis-aligned scale, never a rotation. Left alone, rotating or shearing
a page therefore stretches/skews the untouched appearance content into
the new, differently-shaped /Rect instead of rotating it. Composing the
transform into /Matrix (rather than overwriting it) keeps the rendered
content consistent with the rest of the transformed page while
preserving whatever the annotation already had. Handles both a single
appearance stream and the multi-state /AP /N dict used by
checkbox/radio-button widgets.
"""
if "/AP" not in annotation_obj:
return
ap = cast(DictionaryObject, annotation_obj["/AP"])
if "/N" not in ap:
return
# __getitem__ already resolves indirect references, so this is never None.
normal_ap = ap["/N"]
# /N is a single appearance stream for most annotations, but for
# widgets with multiple states (checkboxes, radio buttons) it is
# instead a dict of named sub-streams, one per state. Only a stream
# carries its own /BBox/Matrix.
state_aps = (
normal_ap.values()
if isinstance(normal_ap, DictionaryObject) and not isinstance(normal_ap, StreamObject)
else [normal_ap]
)
for state_ap in state_aps:
# get_object() is always safe to call: PdfObject.get_object() just
# returns self when the object is already resolved.
state_obj = state_ap.get_object()
if not isinstance(state_obj, StreamObject):
continue
old_matrix = tuple(state_obj.get("/Matrix", (1, 0, 0, 1, 0, 0)))
state_obj[NameObject("/Matrix")] = ArrayObject(
FloatObject(x) for x in Transformation(old_matrix).transform(transformation).ctm
)
+968
View File
@@ -0,0 +1,968 @@
# Copyright (c) 2006, Mathieu Fenniak
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import binascii
import codecs
import hashlib
import re
import sys
from collections.abc import Sequence
from math import log10
from struct import iter_unpack
from typing import Any, Callable, ClassVar, Optional, Union, cast
if sys.version_info[:2] >= (3, 10):
from typing import TypeGuard
else:
from typing_extensions import TypeGuard # PEP 647
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
from .._codecs import _pdfdoc_encoding_rev
from .._protocols import PdfObjectProtocol, PdfWriterProtocol
from .._utils import (
WHITESPACES,
StreamType,
classproperty,
deprecation_no_replacement,
deprecation_with_replacement,
logger_warning,
read_non_whitespace,
read_until_regex,
)
from ..errors import STREAM_TRUNCATED_PREMATURELY, PdfReadError, PdfStreamError
__author__ = "Mathieu Fenniak"
__author_email__ = "biziqe@mathieu.fenniak.net"
class PdfObject(PdfObjectProtocol):
# function for calculating a hash value
hash_func: Callable[..., "hashlib._Hash"] = hashlib.sha1
indirect_reference: Optional["IndirectObject"]
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not implement .hash_bin() so far"
)
def hash_value_data(self) -> bytes:
return f"{self}".encode()
def hash_value(self) -> bytes:
return (
f"{self.__class__.__name__}:"
f"{self.hash_func(self.hash_value_data()).hexdigest()}"
).encode()
def replicate(
self,
pdf_dest: PdfWriterProtocol,
) -> "PdfObject":
"""
Clone object into pdf_dest (PdfWriterProtocol which is an interface for PdfWriter)
without ensuring links. This is used in clone_document_from_root with incremental = True.
Args:
pdf_dest: Target to clone to.
Returns:
The cloned PdfObject
"""
return self.clone(pdf_dest)
def clone(
self,
pdf_dest: PdfWriterProtocol,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "PdfObject":
"""
Clone object into pdf_dest (PdfWriterProtocol which is an interface for PdfWriter).
By default, this method will call ``_reference_clone`` (see ``_reference``).
Args:
pdf_dest: Target to clone to.
force_duplicate: By default, if the object has already been cloned and referenced,
the copy will be returned; when ``True``, a new copy will be created.
(Default value = ``False``)
ignore_fields: List/tuple of field names (for dictionaries) that will be ignored
during cloning (applies to children duplication as well). If fields are to be
considered for a limited number of levels, you have to add it as integer, for
example ``[1,"/B","/TOTO"]`` means that ``"/B"`` will be ignored at the first
level only but ``"/TOTO"`` on all levels.
Returns:
The cloned PdfObject
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not implement .clone so far"
)
def _reference_clone(
self, clone: "PdfObject", pdf_dest: PdfWriterProtocol, force_duplicate: bool = False
) -> "PdfObject":
"""
Reference the object within the _objects of pdf_dest only if
indirect_reference attribute exists (which means the objects was
already identified in xref/xobjstm) if object has been already
referenced do nothing.
Args:
clone:
pdf_dest:
Returns:
The clone
"""
try:
if (
not force_duplicate
and clone.indirect_reference is not None
and clone.indirect_reference.pdf == pdf_dest
):
return clone
except Exception:
pass
# if hasattr(clone, "indirect_reference"):
try:
ind = self.indirect_reference
except AttributeError:
return clone
if (
pdf_dest.incremental
and ind is not None
and ind.pdf == pdf_dest._reader
and ind.idnum <= len(pdf_dest._objects)
):
i = ind.idnum
else:
i = len(pdf_dest._objects) + 1
if ind is not None:
if id(ind.pdf) not in pdf_dest._id_translated:
pdf_dest._id_translated[id(ind.pdf)] = {}
pdf_dest._id_translated[id(ind.pdf)]["PreventGC"] = ind.pdf
if (
not force_duplicate
and ind.idnum in pdf_dest._id_translated[id(ind.pdf)]
):
obj = pdf_dest.get_object(
pdf_dest._id_translated[id(ind.pdf)][ind.idnum]
)
assert isinstance(obj, PdfObject), "mypy"
return obj
pdf_dest._id_translated[id(ind.pdf)][ind.idnum] = i
try:
pdf_dest._objects[i - 1] = clone
except IndexError:
pdf_dest._objects.append(clone)
i = len(pdf_dest._objects)
clone.indirect_reference = IndirectObject(i, 0, pdf_dest)
return clone
def get_object(self) -> Optional["PdfObject"]:
"""Resolve indirect references."""
return self
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
raise NotImplementedError
class NullObject(PdfObject):
def clone(
self,
pdf_dest: PdfWriterProtocol,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "NullObject":
"""Clone object into pdf_dest."""
return cast(
"NullObject", self._reference_clone(NullObject(), pdf_dest, force_duplicate)
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__,))
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(b"null")
@staticmethod
def read_from_stream(stream: StreamType) -> "NullObject":
nulltxt = stream.read(4)
if nulltxt != b"null":
raise PdfReadError("Could not read Null object")
return NullObject()
def __repr__(self) -> str:
return "NullObject"
def __eq__(self, other: object) -> bool:
return isinstance(other, NullObject)
def __hash__(self) -> int:
return self.hash_bin()
class BooleanObject(PdfObject):
value: bool
def __init__(self, value: Any) -> None:
self.value = value
def clone(
self,
pdf_dest: PdfWriterProtocol,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "BooleanObject":
"""Clone object into pdf_dest."""
return cast(
"BooleanObject",
self._reference_clone(BooleanObject(self.value), pdf_dest, force_duplicate),
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, self.value))
def __eq__(self, o: object, /) -> bool:
if isinstance(o, BooleanObject):
return self.value == o.value
if isinstance(o, bool):
return self.value == o
return False
def __hash__(self) -> int:
return self.hash_bin()
def __repr__(self) -> str:
return "True" if self.value else "False"
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
if self.value:
stream.write(b"true")
else:
stream.write(b"false")
@staticmethod
def read_from_stream(stream: StreamType) -> "BooleanObject":
word = stream.read(4)
if word == b"true":
return BooleanObject(True)
if word == b"fals":
stream.read(1)
return BooleanObject(False)
raise PdfReadError("Could not read Boolean object")
class IndirectObject(PdfObject):
_MAXIMUM_PART_LENGTH = 64
def __init__(self, idnum: int, generation: int, pdf: Any) -> None: # PdfReader
self.idnum = idnum
self.generation = generation
self.pdf = pdf
def __hash__(self) -> int:
return hash((self.idnum, self.generation, id(self.pdf)))
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, self.idnum, self.generation, id(self.pdf)))
def replicate(
self,
pdf_dest: PdfWriterProtocol,
) -> "PdfObject":
return IndirectObject(self.idnum, self.generation, pdf_dest)
def clone(
self,
pdf_dest: PdfWriterProtocol,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "IndirectObject":
"""Clone object into pdf_dest."""
if self.pdf == pdf_dest and not force_duplicate:
# Already duplicated and no extra duplication required
return self
if id(self.pdf) not in pdf_dest._id_translated:
pdf_dest._id_translated[id(self.pdf)] = {}
pdf_dest._id_translated[id(self.pdf)]["PreventGC"] = self.pdf
if self.idnum in pdf_dest._id_translated[id(self.pdf)]:
dup = pdf_dest.get_object(pdf_dest._id_translated[id(self.pdf)][self.idnum])
if force_duplicate:
assert dup is not None
assert dup.indirect_reference is not None
idref = dup.indirect_reference
return IndirectObject(idref.idnum, idref.generation, idref.pdf)
else:
obj = self.get_object()
# case observed : a pointed object can not be found
if obj is None:
# this normally
obj = NullObject()
assert isinstance(self, (IndirectObject,))
obj.indirect_reference = self
dup = pdf_dest._add_object(
obj.clone(pdf_dest, force_duplicate, ignore_fields)
)
assert isinstance(dup, PdfObject), "mypy"
assert dup.indirect_reference is not None, "mypy"
return dup.indirect_reference
@property
def indirect_reference(self) -> "IndirectObject": # type: ignore[override]
return self
def get_object(self) -> Optional["PdfObject"]:
obj: Optional[PdfObject] = self.pdf.get_object(self)
return obj
def __deepcopy__(self, memo: Any) -> "IndirectObject":
return IndirectObject(self.idnum, self.generation, self.pdf)
def _get_object_with_check(self) -> Optional["PdfObject"]:
o = self.get_object()
# the check is done here to not slow down get_object()
if isinstance(o, IndirectObject):
raise PdfStreamError(
f"{self.__repr__()} references an IndirectObject {o.__repr__()}"
)
return o
def __getattr__(self, name: str) -> Any:
# Attribute not found in object: look in pointed object
try:
return getattr(self._get_object_with_check(), name)
except AttributeError:
raise AttributeError(
f"No attribute {name} found in IndirectObject or pointed object"
)
def __getitem__(self, key: Any) -> Any:
# items should be extracted from pointed Object
return self._get_object_with_check()[key] # type: ignore[index]
def __contains__(self, key: Any) -> bool:
return key in self._get_object_with_check() # type: ignore[operator]
def __iter__(self) -> Any:
return self._get_object_with_check().__iter__() # type: ignore[union-attr]
def __float__(self) -> str:
# in this case we are looking for the pointed data
return self.get_object().__float__() # type: ignore[union-attr, no-any-return]
def __int__(self) -> int:
# in this case we are looking for the pointed data
return self.get_object().__int__() # type: ignore[union-attr, no-any-return]
def __str__(self) -> str:
# in this case we are looking for the pointed data
return self.get_object().__str__()
def __repr__(self) -> str:
return f"IndirectObject({self.idnum!r}, {self.generation!r}, {id(self.pdf)})"
def __eq__(self, other: object) -> bool:
return (
other is not None
and isinstance(other, IndirectObject)
and self.idnum == other.idnum
and self.generation == other.generation
and self.pdf is other.pdf
)
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(f"{self.idnum} {self.generation} R".encode())
@staticmethod
def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject": # PdfReader
def read_part(name: str, skip_leading_whitespace: bool) -> bytes:
result = bytearray()
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok.isspace() or tok in WHITESPACES:
if skip_leading_whitespace and not result:
continue
break
if len(result) >= IndirectObject._MAXIMUM_PART_LENGTH:
raise PdfReadError(f"{name} exceeds maximum length limit of {IndirectObject._MAXIMUM_PART_LENGTH}.")
result += tok
return bytes(result)
idnum = read_part("Object ID", skip_leading_whitespace=False)
generation = read_part("Generation number", skip_leading_whitespace=True)
r = read_non_whitespace(stream)
if r != b"R":
raise PdfReadError(
f"Error reading indirect object reference at byte {hex(stream.tell())}"
)
try:
return IndirectObject(int(idnum), int(generation), pdf)
except (ValueError, OverflowError) as e:
raise PdfReadError(
f"Invalid indirect object reference ({idnum!r} {generation!r} R): {e}"
) from e
FLOAT_WRITE_PRECISION = 8 # shall be min 5 digits max, allow user adj
class FloatObject(float, PdfObject):
def __new__(
cls, value: Any = "0.0", context: Optional[Any] = None
) -> Self:
try:
value = float(value)
return float.__new__(cls, value)
except Exception as e:
# If this isn't a valid decimal (happens in malformed PDFs)
# fallback to 0
logger_warning(
"%(error)s : FloatObject (%(value)s) invalid; use 0.0 instead",
source=__name__,
error=e,
value=value,
)
return float.__new__(cls, 0.0)
def clone(
self,
pdf_dest: Any,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "FloatObject":
"""Clone object into pdf_dest."""
return cast(
"FloatObject",
self._reference_clone(FloatObject(self), pdf_dest, force_duplicate),
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, self.as_numeric))
def myrepr(self) -> str:
if self == 0: # type: ignore[comparison-overlap]
return "0.0"
nb = FLOAT_WRITE_PRECISION - int(log10(abs(self)))
return f"{self:.{max(1, nb)}f}".rstrip("0").rstrip(".")
def __repr__(self) -> str:
return self.myrepr() # repr(float(self))
def as_numeric(self) -> float:
return float(self)
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(self.myrepr().encode("utf8"))
class NumberObject(int, PdfObject):
NumberPattern = re.compile(b"[^+-.0-9]")
_LENGTH_LIMIT = 64
def __new__(cls, value: Any) -> Self:
try:
return int.__new__(cls, int(value))
except ValueError:
logger_warning("NumberObject(%(value)s) invalid; use 0 instead", source=__name__, value=value)
return int.__new__(cls, 0)
def clone(
self,
pdf_dest: Any,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "NumberObject":
"""Clone object into pdf_dest."""
return cast(
"NumberObject",
self._reference_clone(NumberObject(self), pdf_dest, force_duplicate),
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, self.as_numeric()))
def as_numeric(self) -> int:
return int(repr(self).encode("utf8"))
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(repr(self).encode("utf8"))
@staticmethod
def read_from_stream(stream: StreamType) -> Union["NumberObject", "FloatObject"]:
num = read_until_regex(stream=stream, regex=NumberObject.NumberPattern, length=NumberObject._LENGTH_LIMIT)
if b"." in num:
return FloatObject(num)
return NumberObject(num)
class ByteStringObject(bytes, PdfObject):
"""
Represents a string object where the text encoding could not be determined.
This occurs quite often, as the PDF spec doesn't provide an alternate way to
represent strings -- for example, the encryption data stored in files (like
/O) is clearly not text, but is still stored in a "String" object.
"""
def clone(
self,
pdf_dest: Any,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "ByteStringObject":
"""Clone object into pdf_dest."""
return cast(
"ByteStringObject",
self._reference_clone(
ByteStringObject(bytes(self)), pdf_dest, force_duplicate
),
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, bytes(self)))
@property
def original_bytes(self) -> bytes:
"""For compatibility with TextStringObject.original_bytes."""
return self
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(b"<")
stream.write(binascii.hexlify(self))
stream.write(b">")
def __str__(self) -> str:
charset_to_try = ["utf-16", *list(NameObject.CHARSETS)]
for enc in charset_to_try:
try:
return self.decode(enc)
except UnicodeDecodeError:
pass
raise PdfReadError("Cannot decode ByteStringObject.")
class TextStringObject(str, PdfObject): # noqa: SLOT000
"""
A string object that has been decoded into a real unicode string.
If read from a PDF document, this string appeared to match the
PDFDocEncoding, or contained a UTF-16BE BOM mark to cause UTF-16 decoding
to occur.
"""
autodetect_pdfdocencoding: bool
autodetect_utf16: bool
utf16_bom: bytes
_original_bytes: Optional[bytes] = None
def __new__(cls, value: Any) -> Self:
original_bytes = None
if isinstance(value, bytes):
original_bytes = value
value = value.decode("charmap")
text_string_object = str.__new__(cls, value)
text_string_object._original_bytes = original_bytes
text_string_object.autodetect_utf16 = False
text_string_object.autodetect_pdfdocencoding = False
text_string_object.utf16_bom = b""
if original_bytes is not None and original_bytes[:2] in {codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE}:
# The value of `original_bytes` is only set for inputs being `bytes`.
# If this is UTF-16 data according to the BOM (first two characters),
# perform special handling. All other cases should not need any special conversion
# due to already being a string.
try:
text_string_object = str.__new__(cls, original_bytes.decode("utf-16"))
except UnicodeDecodeError as exception:
logger_warning(
"%(exception)s; initial string: %(initial_string)r",
source=__name__,
exception=exception,
initial_string=exception.object,
)
text_string_object = str.__new__(cls, exception.object[: exception.start].decode("utf-16"))
text_string_object._original_bytes = original_bytes
text_string_object.autodetect_utf16 = True
text_string_object.utf16_bom = original_bytes[:2]
else:
try:
encode_pdfdocencoding(text_string_object)
text_string_object.autodetect_pdfdocencoding = True
except UnicodeEncodeError:
text_string_object.autodetect_utf16 = True
text_string_object.utf16_bom = codecs.BOM_UTF16_BE
return text_string_object
def clone(
self,
pdf_dest: Any,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "TextStringObject":
"""Clone object into pdf_dest."""
obj = TextStringObject(self)
obj._original_bytes = self._original_bytes
obj.autodetect_pdfdocencoding = self.autodetect_pdfdocencoding
obj.autodetect_utf16 = self.autodetect_utf16
obj.utf16_bom = self.utf16_bom
return cast(
"TextStringObject", self._reference_clone(obj, pdf_dest, force_duplicate)
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, self.original_bytes))
@property
def original_bytes(self) -> bytes:
"""
It is occasionally possible that a text string object gets created where
a byte string object was expected due to the autodetection mechanism --
if that occurs, this "original_bytes" property can be used to
back-calculate what the original encoded bytes were.
"""
if self._original_bytes is not None:
return self._original_bytes
return self.get_original_bytes()
def get_original_bytes(self) -> bytes:
# We're a text string object, but the library is trying to get our raw
# bytes. This can happen if we auto-detected this string as text, but
# we were wrong. It's pretty common. Return the original bytes that
# would have been used to create this object, based upon the autodetect
# method.
if self.autodetect_utf16:
if self.utf16_bom == codecs.BOM_UTF16_LE:
return codecs.BOM_UTF16_LE + self.encode("utf-16le")
if self.utf16_bom == codecs.BOM_UTF16_BE:
return codecs.BOM_UTF16_BE + self.encode("utf-16be")
return self.encode("utf-16be")
if self.autodetect_pdfdocencoding:
return encode_pdfdocencoding(self)
raise Exception("no information about original bytes") # pragma: no cover
def get_encoded_bytes(self) -> bytes:
# Try to write the string out as a PDFDocEncoding encoded string. It's
# nicer to look at in the PDF file. Sadly, we take a performance hit
# here for trying...
try:
if self._original_bytes is not None:
return self._original_bytes
if self.autodetect_utf16:
raise UnicodeEncodeError("", "forced", -1, -1, "")
bytearr = encode_pdfdocencoding(self)
except UnicodeEncodeError:
if self.utf16_bom == codecs.BOM_UTF16_LE:
bytearr = codecs.BOM_UTF16_LE + self.encode("utf-16le")
elif self.utf16_bom == codecs.BOM_UTF16_BE:
bytearr = codecs.BOM_UTF16_BE + self.encode("utf-16be")
else:
bytearr = self.encode("utf-16be")
return bytearr
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
bytearr = self.get_encoded_bytes()
stream.write(b"(")
for c_ in iter_unpack("c", bytearr):
c = cast(bytes, c_[0])
if not c.isalnum() and c != b" ":
# This:
# stream.write(rf"\{c:0>3o}".encode())
# gives
# https://github.com/davidhalter/parso/issues/207
stream.write(b"\\%03o" % ord(c))
else:
stream.write(c)
stream.write(b")")
class NameObject(str, PdfObject): # noqa: SLOT000
delimiter_pattern = re.compile(rb"\s+|[\(\)<>\[\]{}/%]")
prefix = b"/"
renumber_table: ClassVar[dict[str, bytes]] = {
**{chr(i): f"#{i:02X}".encode() for i in b"#()<>[]{}/%"},
**{chr(i): f"#{i:02X}".encode() for i in range(33)},
}
_LENGTH_LIMIT = 4096
def clone(
self,
pdf_dest: Any,
force_duplicate: bool = False,
ignore_fields: Optional[Sequence[Union[str, int]]] = (),
) -> "NameObject":
"""Clone object into pdf_dest."""
return cast(
"NameObject",
self._reference_clone(NameObject(self), pdf_dest, force_duplicate),
)
def hash_bin(self) -> int:
"""
Used to detect modified object.
Returns:
Hash considering type and value.
"""
return hash((self.__class__, self))
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(self.renumber())
def renumber(self) -> bytes:
out = self[0].encode("utf-8")
if out != b"/":
deprecation_no_replacement(
f"Incorrect first char in NameObject, should start with '/': ({self})",
"5.0.0",
)
parts = [out]
for c in self[1:]:
if c > "~":
parts.extend(f"#{x:02X}".encode() for x in c.encode("utf-8"))
else:
try:
parts.append(self.renumber_table[c])
except KeyError:
parts.append(c.encode("utf-8"))
return b"".join(parts)
def _sanitize(self) -> "NameObject":
"""
Sanitize the NameObject's name to be a valid PDF name part
(alphanumeric, underscore, hyphen). The _sanitize method replaces
spaces and any non-alphanumeric/non-underscore/non-hyphen with
underscores.
Returns:
NameObject with sanitized name.
"""
name = str(self).removeprefix("/")
name = re.sub(r" ", "_", name)
name = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
return NameObject("/" + name)
@classproperty
def surfix(cls) -> bytes: # noqa: N805
deprecation_with_replacement("surfix", "prefix", "5.0.0")
return b"/"
@staticmethod
def unnumber(sin: bytes) -> bytes:
result = bytearray()
i = 0
while i < len(sin):
if sin[i:i + 1] == b"#":
try:
result.append(int(sin[i + 1 : i + 3], 16))
i += 3
continue
except (ValueError, IndexError):
# if the 2 characters after # can not be converted to hex
# we change nothing and carry on
pass
result.append(sin[i])
i += 1
return bytes(result)
CHARSETS = ("utf-8", "gbk", "latin1")
@staticmethod
def read_from_stream(stream: StreamType, pdf: Any) -> "NameObject": # PdfReader
name = stream.read(1)
if name != NameObject.prefix:
raise PdfReadError("Name read error")
name += read_until_regex(stream=stream, regex=NameObject.delimiter_pattern, length=NameObject._LENGTH_LIMIT)
try:
# Name objects should represent irregular characters
# with a '#' followed by the symbol's hex number
name = NameObject.unnumber(name)
for enc in NameObject.CHARSETS:
try:
ret = name.decode(enc)
return NameObject(ret)
except Exception:
pass
raise UnicodeDecodeError("", name, 0, 0, "Code Not Found")
except (UnicodeEncodeError, UnicodeDecodeError) as e:
if not pdf.strict:
logger_warning(
"Illegal character in NameObject (%(name)r), you may need to adjust NameObject.CHARSETS",
source=__name__,
name=name,
)
return NameObject(name.decode("charmap"))
raise PdfReadError(
f"Illegal character in NameObject ({name!r}). "
"You may need to adjust NameObject.CHARSETS.",
) from e
def encode_pdfdocencoding(unicode_string: str) -> bytes:
try:
return bytes([_pdfdoc_encoding_rev[k] for k in unicode_string])
except KeyError:
raise UnicodeEncodeError(
"pdfdocencoding",
unicode_string,
-1,
-1,
"does not exist in translation table",
)
def is_null_or_none(x: Any) -> TypeGuard[Union[NullObject, IndirectObject, None]]:
"""
Returns:
True if x is None or NullObject.
"""
return x is None or (
isinstance(x, PdfObject)
and (x.get_object() is None or isinstance(x.get_object(), NullObject))
)
+85
View File
@@ -0,0 +1,85 @@
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import ClassVar, Union
@dataclass
class Color:
"""
A factory class to generate one of class GrayscaleColor, RGBColor or CMYKColor. Call with
Color.from_normalized_values() on a tuple of length 1 for grayscale, 3 for RGB, or 4 for CMYK.
"""
color_operator: str = field(init=False)
_ordered_fields: ClassVar[tuple[str, ...]]
@classmethod
def from_normalized_values(
cls,
color: Union[Sequence[float], None]
) -> Union["Color", None]:
"""
Method to instantiate a color class. Can be called with value of None for cases where an appearance
characteristics dictionary contains an empty "/BG" or "/BC" value, which returns None and signifies
transparent color. See Table 189, "Entries in an appearance characteristics dictionary" of the PDF
specification 1.7.
Args:
color: A sequence of 1 (for DeviceGray), 3 (for DeviceRGB) or 4 (for DeviceCMYK) float values
in the range of 0.0 to 1.0 (representing normalized color channel values), or None to return None.
"""
color_types: dict[int, type[Color]] = {
1: DeviceGray,
3: DeviceRGB,
4: DeviceCMYK,
}
if (
color is not None
and (color_length := len(color)) in color_types
and all(isinstance(val, (int, float)) and 0.0 <= val <= 1.0 for val in color)
):
# Create instance of the appropriate subclass
color_subclass = color_types[color_length]
kwargs = dict(zip(color_subclass._ordered_fields, color))
return color_subclass(**kwargs)
return None
def as_operator(self, stroke: bool = False) -> str:
"""
Returns the PDF color operator as a string.
Args:
stroke: Returns stroke (i.e., uppercase) color operator if True
"""
values = [f"{round(getattr(self, field), 3):g}" for field in self._ordered_fields]
return f"{' '.join(values)} {self.color_operator.upper() if stroke else self.color_operator}"
@dataclass
class DeviceGray(Color):
gray: float = 0.0
color_operator = "g"
_ordered_fields = ("gray",)
@dataclass
class DeviceRGB(Color):
red: float = 0.0
green: float = 0.0
blue: float = 0.0
color_operator = "rg"
_ordered_fields = ("red", "green", "blue")
@dataclass
class DeviceCMYK(Color):
cyan: float = 0.0
magenta: float = 0.0
yellow: float = 0.0
black: float = 1.0
color_operator = "k"
_ordered_fields = ("cyan", "magenta", "yellow", "black")
File diff suppressed because it is too large. Load diff
+465
View File
@@ -0,0 +1,465 @@
from __future__ import annotations
import bisect
from functools import cached_property
from typing import TYPE_CHECKING, cast
from pypdf._utils import format_iso8824_date, logger_warning, parse_iso8824_date
from pypdf.constants import CatalogAttributes as CA
from pypdf.constants import FileSpecificationDictionaryEntries
from pypdf.constants import PageAttributes as PG
from pypdf.errors import PdfReadError, PyPdfError
from pypdf.generic import (
ArrayObject,
ByteStringObject,
DecodedStreamObject,
DictionaryObject,
NameObject,
NullObject,
NumberObject,
StreamObject,
TextStringObject,
is_null_or_none,
)
if TYPE_CHECKING:
import datetime
from collections.abc import Iterator
from pypdf._writer import PdfWriter
class EmbeddedFile:
"""
Container holding the information on an embedded file.
Attributes are evaluated lazily if possible.
Further information on embedded files can be found in section 7.11 of the PDF 2.0 specification.
"""
def __init__(self, name: str, pdf_object: DictionaryObject, parent: ArrayObject | None = None) -> None:
"""
Args:
name: The (primary) name as provided in the name tree.
pdf_object: The corresponding PDF object to allow retrieving further data.
parent: The parent list.
"""
self._name = name
self.pdf_object = pdf_object
self._parent = parent
@property
def name(self) -> str:
"""
The (primary) name of the embedded file as provided in the name tree.
.. warning::
This value can contain arbitrary characters. Please make sure to sanitize it before
using it to write the file content to the disk for example.
"""
return self._name
@classmethod
def _create_new(cls, writer: PdfWriter, name: str, content: str | bytes) -> EmbeddedFile:
"""
Create a new embedded file and add it to the PdfWriter.
Args:
writer: The PdfWriter instance to add the embedded file to.
name: The filename to display.
content: The data in the file.
Returns:
EmbeddedFile instance for the newly created embedded file.
"""
# Convert string content to bytes if needed
if isinstance(content, str):
content = content.encode("latin-1")
# Create the file entry (the actual embedded file stream)
file_entry = DecodedStreamObject()
file_entry.set_data(content)
file_entry.update({NameObject(PG.TYPE): NameObject("/EmbeddedFile")})
# Create the /EF entry
ef_entry = DictionaryObject()
ef_entry.update({NameObject("/F"): writer._add_object(file_entry)})
# Create the filespec dictionary
from pypdf.generic import create_string_object # noqa: PLC0415
filespec = DictionaryObject()
filespec_reference = writer._add_object(filespec)
name_object = cast(TextStringObject, create_string_object(name))
filespec.update(
{
NameObject(PG.TYPE): NameObject("/Filespec"),
NameObject(FileSpecificationDictionaryEntries.F): name_object,
NameObject(FileSpecificationDictionaryEntries.EF): ef_entry,
}
)
# Add the name and filespec to the names array.
# We use the inverse order for insertion, as this allows us to re-use the
# same index.
names_array = cls._get_names_array(writer)
insertion_index = cls._get_insertion_index(names_array, name_object)
names_array.insert(insertion_index, filespec_reference)
names_array.insert(insertion_index, name_object)
# Return an EmbeddedFile instance
return cls(name=name, pdf_object=filespec, parent=names_array)
@classmethod
def _get_names_array(cls, writer: PdfWriter) -> ArrayObject:
"""Get the names array for embedded files, possibly creating and flattening it."""
if CA.NAMES not in writer.root_object:
# Add the /Names entry to the catalog.
writer.root_object[NameObject(CA.NAMES)] = writer._add_object(DictionaryObject())
names_dict = cast(DictionaryObject, writer.root_object[CA.NAMES])
if "/EmbeddedFiles" not in names_dict:
# We do not yet have an entry for embedded files. Create and return it.
names = ArrayObject()
embedded_files_names_dictionary = DictionaryObject(
{NameObject(CA.NAMES): names}
)
names_dict[NameObject("/EmbeddedFiles")] = writer._add_object(embedded_files_names_dictionary)
return names
# We have an existing embedded files entry.
embedded_files_names_tree = cast(DictionaryObject, names_dict["/EmbeddedFiles"])
if "/Names" in embedded_files_names_tree:
# Simple case: We already have a flat list.
return cast(ArrayObject, embedded_files_names_tree[NameObject(CA.NAMES)])
if "/Kids" not in embedded_files_names_tree:
# Invalid case: This is no name tree.
raise PdfReadError("Got neither Names nor Kids in embedded files tree.")
# Complex case: Convert a /Kids-based name tree to a /Names-based one.
# /Name-based ones are much easier to handle and allow us to simplify the
# actual insertion logic by only having to consider one case.
names = ArrayObject()
kids = cast(ArrayObject, embedded_files_names_tree["/Kids"].get_object())
embedded_files_names_dictionary = DictionaryObject(
{NameObject(CA.NAMES): names}
)
names_dict[NameObject("/EmbeddedFiles")] = writer._add_object(embedded_files_names_dictionary)
for kid in kids:
# Write the flattened file entries. As we do not change the actual files,
# this should not have any impact on references to them.
# There might be further (nested) kids here.
# Wait for an example before evaluating an implementation.
for name in kid.get_object().get("/Names", []):
names.append(name)
return names
@classmethod
def _get_insertion_index(cls, names_array: ArrayObject, name: str) -> int:
keys = [names_array[i].encode("utf-8") for i in range(0, len(names_array), 2)]
name_bytes = name.encode("utf-8")
start = bisect.bisect_left(keys, name_bytes)
end = bisect.bisect_right(keys, name_bytes)
if start != end:
return end * 2
if start == 0:
return 0
if start == (key_count := len(keys)):
return key_count * 2
return end * 2
@property
def alternative_name(self) -> str | None:
"""
Retrieve the alternative name (as per the file specification dictionary).
.. warning::
This value can contain arbitrary characters. Please make sure to sanitize it before
using it to write the file content to the disk for example.
"""
for key in [FileSpecificationDictionaryEntries.UF, FileSpecificationDictionaryEntries.F]:
# PDF 2.0 reference, table 43:
# > A PDF reader shall use the value of the UF key, when present, instead of the F key.
if key in self.pdf_object:
value = self.pdf_object[key].get_object()
if not is_null_or_none(value):
return cast(str, value)
return None
@alternative_name.setter
def alternative_name(self, value: TextStringObject | None) -> None:
"""Set the alternative name (as per the file specification dictionary)."""
if value is None:
if FileSpecificationDictionaryEntries.UF in self.pdf_object:
self.pdf_object[NameObject(FileSpecificationDictionaryEntries.UF)] = NullObject()
if FileSpecificationDictionaryEntries.F in self.pdf_object:
self.pdf_object[NameObject(FileSpecificationDictionaryEntries.F)] = NullObject()
else:
self.pdf_object[NameObject(FileSpecificationDictionaryEntries.UF)] = value
self.pdf_object[NameObject(FileSpecificationDictionaryEntries.F)] = value
@property
def description(self) -> str | None:
"""Retrieve the description."""
value = self.pdf_object.get(FileSpecificationDictionaryEntries.DESC)
if is_null_or_none(value):
return None
return value
@description.setter
def description(self, value: TextStringObject | None) -> None:
"""Set the description."""
if value is None:
self.pdf_object[NameObject(FileSpecificationDictionaryEntries.DESC)] = NullObject()
else:
self.pdf_object[NameObject(FileSpecificationDictionaryEntries.DESC)] = value
@property
def associated_file_relationship(self) -> str:
"""Retrieve the relationship of the referring document to this embedded file."""
return cast(
NameObject,
self.pdf_object.get("/AFRelationship", NameObject("/Unspecified")),
)
@associated_file_relationship.setter
def associated_file_relationship(self, value: NameObject) -> None:
"""Set the relationship of the referring document to this embedded file."""
self.pdf_object[NameObject("/AFRelationship")] = value
@property
def _embedded_file(self) -> StreamObject:
"""Retrieve the actual embedded file stream."""
if "/EF" not in self.pdf_object:
raise PdfReadError(f"/EF entry not found: {self.pdf_object}")
ef = cast(DictionaryObject, self.pdf_object["/EF"])
for key in [FileSpecificationDictionaryEntries.UF, FileSpecificationDictionaryEntries.F]:
if key in ef:
return cast(StreamObject, ef[key].get_object())
raise PdfReadError(f"No /(U)F key found in file dictionary: {ef}")
@property
def _params(self) -> DictionaryObject:
"""Retrieve the file-specific parameters."""
return cast(DictionaryObject, self._embedded_file.get("/Params", DictionaryObject()).get_object())
@cached_property
def _ensure_params(self) -> DictionaryObject:
"""Ensure the /Params dictionary exists and return it."""
embedded_file = self._embedded_file
if "/Params" not in embedded_file:
embedded_file[NameObject("/Params")] = DictionaryObject()
return cast(DictionaryObject, embedded_file["/Params"])
@property
def subtype(self) -> str | None:
"""Retrieve the subtype. This is a MIME media type, prefixed by a slash."""
value = self._embedded_file.get("/Subtype")
if is_null_or_none(value):
return None
return value
@subtype.setter
def subtype(self, value: NameObject | None) -> None:
"""Set the subtype. This should be a MIME media type, prefixed by a slash."""
embedded_file = self._embedded_file
if value is None:
embedded_file[NameObject("/Subtype")] = NullObject()
else:
embedded_file[NameObject("/Subtype")] = value
@property
def content(self) -> bytes:
"""Retrieve the actual file content."""
return self._embedded_file.get_data()
@content.setter
def content(self, value: str | bytes) -> None:
"""Set the file content."""
if isinstance(value, str):
value = value.encode("latin-1")
self._embedded_file.set_data(value)
@property
def size(self) -> int | None:
"""Retrieve the size of the uncompressed file in bytes."""
value = self._params.get("/Size")
if is_null_or_none(value):
return None
return value
@size.setter
def size(self, value: NumberObject | None) -> None:
"""Set the size of the uncompressed file in bytes."""
params = self._ensure_params
if value is None:
params[NameObject("/Size")] = NullObject()
else:
params[NameObject("/Size")] = value
@property
def creation_date(self) -> datetime.datetime | None:
"""Retrieve the file creation datetime."""
return parse_iso8824_date(self._params.get("/CreationDate"))
@creation_date.setter
def creation_date(self, value: datetime.datetime | None) -> None:
"""Set the file creation datetime."""
params = self._ensure_params
if value is None:
params[NameObject("/CreationDate")] = NullObject()
else:
date_str = format_iso8824_date(value)
params[NameObject("/CreationDate")] = TextStringObject(date_str)
@property
def modification_date(self) -> datetime.datetime | None:
"""Retrieve the datetime of the last file modification."""
return parse_iso8824_date(self._params.get("/ModDate"))
@modification_date.setter
def modification_date(self, value: datetime.datetime | None) -> None:
"""Set the datetime of the last file modification."""
params = self._ensure_params
if value is None:
params[NameObject("/ModDate")] = NullObject()
else:
date_str = format_iso8824_date(value)
params[NameObject("/ModDate")] = TextStringObject(date_str)
@property
def checksum(self) -> bytes | None:
"""Retrieve the MD5 checksum of the (uncompressed) file."""
value = self._params.get("/CheckSum")
if is_null_or_none(value):
return None
return value
@checksum.setter
def checksum(self, value: ByteStringObject | None) -> None:
"""Set the MD5 checksum of the (uncompressed) file."""
params = self._ensure_params
if value is None:
params[NameObject("/CheckSum")] = NullObject()
else:
params[NameObject("/CheckSum")] = value
@property
def _names(self) -> Iterator[str]:
yield self.name
if self.alternative_name is not None and self.alternative_name != self.name:
yield self.alternative_name
def delete(self) -> None:
"""Delete the file from the document."""
if not self._parent:
raise PyPdfError("Parent required to delete file from document.")
if self.pdf_object in self._parent:
index = self._parent.index(self.pdf_object)
elif (
(indirect_reference := getattr(self.pdf_object, "indirect_reference", None)) is not None
and indirect_reference in self._parent
):
index = self._parent.index(indirect_reference)
else:
raise PyPdfError("File not found in parent object.")
self._parent.pop(index) # Reference.
self._parent.pop(index - 1) # Name.
self.pdf_object = DictionaryObject() # Invalidate.
def __repr__(self) -> str:
return f"<{self.__class__.__name__} name={self.name!r}>"
@staticmethod
def _report_malformed(message: str, strict: bool) -> None:
"""Raise an exception in strict mode, warn otherwise."""
if strict:
raise PdfReadError(message)
logger_warning(message, source=__name__)
@classmethod
def _load_from_names(cls, names: ArrayObject) -> Iterator[EmbeddedFile]:
"""
Convert the given name tree into class instances.
Args:
names: The name tree to load the data from.
Returns:
Iterable of class instances for the files found.
"""
# This is a name tree of the format [name_1, reference_1, name_2, reference_2, ...]
for i, name in enumerate(names):
if not isinstance(name, str):
# Skip plain strings and retrieve them as `direct_name` by index.
file_dictionary = name.get_object()
direct_name = names[i - 1].get_object()
yield EmbeddedFile(name=direct_name, pdf_object=file_dictionary, parent=names)
@classmethod
def _load(cls, catalog: DictionaryObject, strict: bool = False) -> Iterator[EmbeddedFile]:
"""
Load the embedded files for the given document catalog.
This method and its signature are considered internal API and thus not exposed publicly for now.
Args:
catalog: The document catalog to load from.
strict: Whether to raise an exception on malformed name trees
instead of issuing a warning and skipping them.
Returns:
Iterable of class instances for the files found.
"""
if "/Names" not in catalog:
return
names = catalog["/Names"]
if isinstance(names, NullObject):
# A null value is equivalent to an omitted entry.
return
if not isinstance(names, DictionaryObject):
cls._report_malformed(f"Names tree is not a dictionary: {names}", strict=strict)
return
if "/EmbeddedFiles" not in names:
return
container = names["/EmbeddedFiles"]
if isinstance(container, NullObject):
return
if not isinstance(container, DictionaryObject):
cls._report_malformed(f"Embedded files entry is not a dictionary: {container}", strict=strict)
return
if "/Kids" in container:
kids = container["/Kids"].get_object()
if isinstance(kids, NullObject):
kids = ArrayObject()
elif not isinstance(kids, ArrayObject):
cls._report_malformed(f"Embedded files /Kids is not an array: {kids}", strict=strict)
return
for kid in kids:
# There might be further (nested) kids here.
# Wait for an example before evaluating an implementation.
kid = kid.get_object()
if not isinstance(kid, DictionaryObject):
cls._report_malformed(f"Embedded files kid is not a dictionary: {kid}", strict=strict)
continue
if "/Names" not in kid:
continue
kid_names = kid["/Names"].get_object()
if isinstance(kid_names, NullObject):
continue
if not isinstance(kid_names, ArrayObject):
cls._report_malformed(f"Embedded files name list is not an array: {kid_names}", strict=strict)
continue
yield from cls._load_from_names(kid_names)
if "/Names" in container:
container_names = container["/Names"].get_object()
if isinstance(container_names, NullObject):
return
if not isinstance(container_names, ArrayObject):
cls._report_malformed(f"Embedded files name list is not an array: {container_names}", strict=strict)
return
yield from cls._load_from_names(container_names)
+175
View File
@@ -0,0 +1,175 @@
from collections.abc import Sequence
from typing import Any, Optional, Union
from ._base import is_null_or_none
class Fit:
def __init__(
self, fit_type: str, fit_args: Sequence[Union[float, Any, None]] = ()
) -> None:
from ._base import FloatObject, NameObject, NullObject, NumberObject # noqa: PLC0415
self.fit_type = NameObject(fit_type)
self.fit_args: list[Union[NullObject, FloatObject, NumberObject]] = [
NullObject() if is_null_or_none(a) else FloatObject(a) for a in fit_args
]
@classmethod
def xyz(
cls,
left: Optional[float] = None,
top: Optional[float] = None,
zoom: Optional[float] = None,
) -> "Fit":
"""
Display the page designated by page, with the coordinates (left, top)
positioned at the upper-left corner of the window and the contents
of the page magnified by the factor zoom.
A null value for any of the parameters left, top, or zoom specifies
that the current value of that parameter is to be retained unchanged.
A zoom value of 0 has the same meaning as a null value.
Args:
left:
top:
zoom:
Returns:
The created fit object.
"""
return Fit(fit_type="/XYZ", fit_args=(left, top, zoom))
@classmethod
def fit(cls) -> "Fit":
"""
Display the page designated by page, with its contents magnified just
enough to fit the entire page within the window both horizontally and
vertically.
If the required horizontal and vertical magnification factors are
different, use the smaller of the two, centering the page within the
window in the other dimension.
"""
return Fit(fit_type="/Fit")
@classmethod
def fit_horizontally(cls, top: Optional[float] = None) -> "Fit":
"""
Display the page designated by page, with the vertical coordinate top
positioned at the top edge of the window and the contents of the page
magnified just enough to fit the entire width of the page within the
window.
A null value for ``top`` specifies that the current value of that
parameter is to be retained unchanged.
Args:
top:
Returns:
The created fit object.
"""
return Fit(fit_type="/FitH", fit_args=(top,))
@classmethod
def fit_vertically(cls, left: Optional[float] = None) -> "Fit":
return Fit(fit_type="/FitV", fit_args=(left,))
@classmethod
def fit_rectangle(
cls,
left: Optional[float] = None,
bottom: Optional[float] = None,
right: Optional[float] = None,
top: Optional[float] = None,
) -> "Fit":
"""
Display the page designated by page, with its contents magnified
just enough to fit the rectangle specified by the coordinates
left, bottom, right, and top entirely within the window
both horizontally and vertically.
If the required horizontal and vertical magnification factors are
different, use the smaller of the two, centering the rectangle within
the window in the other dimension.
A null value for any of the parameters may result in unpredictable
behavior.
Args:
left:
bottom:
right:
top:
Returns:
The created fit object.
"""
return Fit(fit_type="/FitR", fit_args=(left, bottom, right, top))
@classmethod
def fit_box(cls) -> "Fit":
"""
Display the page designated by page, with its contents magnified just
enough to fit its bounding box entirely within the window both
horizontally and vertically.
If the required horizontal and vertical magnification factors are
different, use the smaller of the two, centering the bounding box
within the window in the other dimension.
"""
return Fit(fit_type="/FitB")
@classmethod
def fit_box_horizontally(cls, top: Optional[float] = None) -> "Fit":
"""
Display the page designated by page, with the vertical coordinate top
positioned at the top edge of the window and the contents of the page
magnified just enough to fit the entire width of its bounding box
within the window.
A null value for top specifies that the current value of that parameter
is to be retained unchanged.
Args:
top:
Returns:
The created fit object.
"""
return Fit(fit_type="/FitBH", fit_args=(top,))
@classmethod
def fit_box_vertically(cls, left: Optional[float] = None) -> "Fit":
"""
Display the page designated by page, with the horizontal coordinate
left positioned at the left edge of the window and the contents of the
page magnified just enough to fit the entire height of its bounding box
within the window.
A null value for left specifies that the current value of that
parameter is to be retained unchanged.
Args:
left:
Returns:
The created fit object.
"""
return Fit(fit_type="/FitBV", fit_args=(left,))
def __str__(self) -> str:
if not self.fit_args:
return f"Fit({self.fit_type})"
return f"Fit({self.fit_type}, {self.fit_args})"
DEFAULT_FIT = Fit.fit()
+365
View File
@@ -0,0 +1,365 @@
# Copyright (c) 2024, pypdf contributors
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import logging
from io import BytesIO
from typing import IO
from .._utils import (
WHITESPACES,
WHITESPACES_AS_BYTES,
BinaryStreamType,
StreamType,
logger_warning,
read_non_whitespace,
)
from ..errors import PdfReadError
logger = logging.getLogger(__name__)
# An inline image should be used only for small images (4096 bytes or less),
# but allow twice this for cases where this has been exceeded.
BUFFER_SIZE = 8192
def _check_end_image_marker(stream: StreamType) -> bool:
ei_tok = read_non_whitespace(stream)
ei_tok += stream.read(2)
stream.seek(-3, 1)
return ei_tok[:2] == b"EI" and (ei_tok[2:3] == b"" or ei_tok[2:3] in WHITESPACES)
def extract_inline__ascii_hex_decode(stream: StreamType) -> bytes:
"""
Extract HexEncoded stream from inline image.
The stream will be moved onto the EI.
"""
data_out = bytearray()
# Read data until delimiter > and EI as backup.
while True:
data_buffered = read_non_whitespace(stream) + (read_bytes := stream.read(BUFFER_SIZE))
if not data_buffered or not read_bytes:
raise PdfReadError("Unexpected end of stream.")
pos_tok = data_buffered.find(b">")
if pos_tok >= 0: # found >
data_out += data_buffered[: pos_tok + 1]
stream.seek(-len(data_buffered) + pos_tok + 1, 1)
break
pos_ei = data_buffered.find(b"EI")
if pos_ei >= 0: # found EI
stream.seek(-len(data_buffered) + pos_ei - 1, 1)
c = stream.read(1)
while c in WHITESPACES:
stream.seek(-2, 1)
c = stream.read(1)
pos_ei -= 1
data_out += data_buffered[:pos_ei]
break
if len(data_buffered) == 2:
data_out += data_buffered
raise PdfReadError("Unexpected end of stream.")
# Neither > nor EI found
data_out += data_buffered[:-2]
stream.seek(-2, 1)
if not _check_end_image_marker(stream):
raise PdfReadError("EI stream not found.")
return bytes(data_out)
def extract_inline__ascii85_decode(stream: StreamType) -> bytes:
"""
Extract A85 stream from inline image.
The stream will be moved onto the EI.
"""
data_out = bytearray()
# Read data until delimiter ~>
while True:
data_buffered = read_non_whitespace(stream) + (read_bytes := stream.read(BUFFER_SIZE))
if not data_buffered or not read_bytes:
raise PdfReadError("Unexpected end of stream.")
pos_tok = data_buffered.find(b"~>")
if pos_tok >= 0: # found!
data_out += data_buffered[: pos_tok + 2]
stream.seek(-len(data_buffered) + pos_tok + 2, 1)
break
if len(data_buffered) == 2: # end of buffer
data_out += data_buffered
raise PdfReadError("Unexpected end of stream.")
data_out += data_buffered[
:-2
] # back by one char in case of in the middle of ~>
stream.seek(-2, 1)
if not _check_end_image_marker(stream):
raise PdfReadError("EI stream not found.")
return bytes(data_out)
def extract_inline__run_length_decode(stream: StreamType) -> bytes:
"""
Extract RL (RunLengthDecode) stream from inline image.
The stream will be moved onto the EI.
"""
data_out = bytearray()
# Read data until delimiter 128
while True:
data_buffered = stream.read(BUFFER_SIZE)
if not data_buffered:
raise PdfReadError("Unexpected end of stream.")
pos_tok = data_buffered.find(b"\x80")
if pos_tok >= 0: # found
# Ideally, we could just use plain run-length decoding here, where 80_16 = 128_10
# marks the EOD. But there apparently are cases like in issue #3517, where we have
# an inline image with up to 51 EOD markers. In these cases, be resilient here and
# use the default `EI` marker detection instead. Please note that this fallback
# still omits special `EI` handling within the stream, but for now assume that having
# both of these cases occur at the same time is very unlikely (and the image stream
# is broken anyway).
# For now, do not skip over more than one whitespace character.
after_token = data_buffered[pos_tok + 1 : pos_tok + 4]
if after_token.startswith(b"EI") or after_token.endswith(b"EI"):
data_out += data_buffered[: pos_tok + 1]
stream.seek(-len(data_buffered) + pos_tok + 1, 1)
else:
logger_warning("Early EOD in RunLengthDecode of inline image, using fallback.", source=__name__)
ei_marker = data_buffered.find(b"EI")
if ei_marker > 0:
data_out += data_buffered[: ei_marker]
stream.seek(-len(data_buffered) + ei_marker - 1, 1)
break
data_out += data_buffered
if not _check_end_image_marker(stream):
raise PdfReadError("EI stream not found.")
return bytes(data_out)
def extract_inline__dct_decode(stream: BinaryStreamType) -> bytes:
"""
Extract DCT (JPEG) stream from inline image.
The stream will be moved onto the EI.
"""
def read(length: int) -> bytes:
# If 0 bytes are returned, and *size* was not 0, this indicates end of file.
# If the object is in non-blocking mode and no bytes are available, `None` is returned.
_result = stream.read(length)
if _result is None or len(_result) != length:
raise PdfReadError("Unexpected end of stream.")
return _result
data_out = bytearray()
# Read Blocks of data (ID/Size/data) up to ID=FF/D9
# https://www.digicamsoft.com/itu/itu-t81-36.html
not_first = False
while True:
c = read(1)
if not_first or (c == b"\xff"):
data_out += c
if c != b"\xff":
continue
not_first = True
c = read(1)
data_out += c
if c == b"\xff":
stream.seek(-1, 1) # pragma: no cover
elif c == b"\x00": # stuffing
pass
elif c == b"\xd9": # end
break
elif c in (
b"\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc9\xca\xcb\xcc\xcd\xce\xcf"
b"\xda\xdb\xdc\xdd\xde\xdf"
b"\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xfe"
):
c = read(2)
data_out += c
sz = c[0] * 256 + c[1]
data_out += read(sz - 2)
if not _check_end_image_marker(stream):
raise PdfReadError("EI stream not found.")
return bytes(data_out)
def _is_preceded_by_whitespace(stream_out: BytesIO, position: int) -> bool:
"""
Check whether the byte in front of `position` is a whitespace.
Returns:
Whether the preceding byte is whitespace. If no preceding byte exists,
returns ``False`` because the ``EI`` marker is at the beginning of the
image data and is therefore not delimited by anything.
"""
if position <= 0:
return False
saved_position = stream_out.tell()
# Avoid exporting the BytesIO buffer: PyPy may keep the view alive,
# preventing later writes from resizing the output stream.
stream_out.seek(position - 1)
is_whitespace = stream_out.read(1) in WHITESPACES
stream_out.seek(saved_position)
return is_whitespace
def _is_followed_by_closing_operator(stream: StreamType, first_byte: bytes) -> bool:
"""
Check whether `first_byte` starts an exact `Q` or `EMC` operator.
`first_byte` has already been consumed from the stream, which is restored to its
current position afterwards. Looking at this byte alone is not sufficient, as
binary image data can easily be mistaken for one of these operators.
"""
position = stream.tell()
if first_byte == b"Q":
suffix = stream.read(1)
is_closing_operator = suffix == b"" or suffix in WHITESPACES
elif first_byte == b"E":
suffix = stream.read(3)
is_closing_operator = suffix[:2] == b"MC" and (suffix[2:3] == b"" or suffix[2:3] in WHITESPACES)
else:
is_closing_operator = False
stream.seek(position, 0)
return is_closing_operator
def extract_inline_default(stream: StreamType) -> bytes:
"""Legacy method, used by default"""
stream_out = BytesIO()
# Read the inline image, while checking for EI (End Image) operator.
while True:
data_buffered = stream.read(BUFFER_SIZE)
if not data_buffered:
raise PdfReadError("Unexpected end of stream.")
pos_ei = data_buffered.find(
b"E"
) # We can not look straight for "EI" because it may not have been loaded in the buffer
if pos_ei == -1:
stream_out.write(data_buffered)
else:
# Write out everything including E (the one from EI to be removed)
stream_out.write(data_buffered[0 : pos_ei + 1])
sav_pos_ei = stream_out.tell() - 1
# Seek back in the stream to read the E next
stream.seek(pos_ei + 1 - len(data_buffered), 1)
saved_pos = stream.tell()
# Check for End Image
tok2 = stream.read(1) # I of "EI"
if tok2 != b"I":
stream.seek(saved_pos, 0)
continue
tok3 = stream.read(1) # possible space after "EI"
if tok3 == b"":
# The `EI` marker is at the very end of the stream. There can be
# no trailing binary data, so this is unambiguously the end of the
# inline image (#3468).
stream.seek(saved_pos - 1, 0)
stream_out.truncate(sav_pos_ei)
break
if tok3 not in WHITESPACES:
stream.seek(saved_pos, 0)
continue
while tok3 in WHITESPACES:
tok3 = stream.read(1)
# The `EI` marker should be delimited by whitespace on both sides. Preserve the
# legacy tolerance for a missing leading delimiter only when it is followed by an
# exact closing operator, as binary image data would match otherwise.
if not _is_preceded_by_whitespace(stream_out, sav_pos_ei) and not _is_followed_by_closing_operator(
stream, tok3
):
stream.seek(saved_pos, 0)
continue
if is_followed_by_binary_data(stream):
# Inline image contains `EI ` sequence usually marking the end of it, but
# is followed by binary data which does not make sense for the actual end.
stream.seek(saved_pos, 0)
continue
# Data contains [\s]EI[\s](Q|EMC): 4 chars are sufficient
# remove E(I) wrongly inserted earlier
stream.seek(saved_pos - 1, 0)
stream_out.truncate(sav_pos_ei)
break
return stream_out.getvalue()
def is_followed_by_binary_data(stream: IO[bytes], length: int = 10) -> bool:
"""
Check if the next bytes of the stream look like binary image data or regular page content.
This is just some heuristics due to the PDF specification being too imprecise about
inline images containing the `EI` marker which would end an image. Starting with PDF 2.0,
we finally get a mandatory length field, but with (proper) PDF 2.0 support being very limited
everywhere, we should not expect to be able to remove such hacks in the near future - especially
considering legacy documents as well.
The actual implementation draws some inspiration from
https://github.com/itext/itext-java/blob/9.1.0/kernel/src/main/java/com/itextpdf/kernel/pdf/canvas/parser/util/InlineImageParsingUtils.java
"""
position = stream.tell()
data = stream.read(length)
stream.seek(position)
if not data:
return False
operator_start = None
operator_end = None
for index, byte in enumerate(data):
if byte < 32 and byte not in WHITESPACES_AS_BYTES:
# This covers all characters not being displayable directly, although omitting whitespace
# to allow for operator detection.
return True
is_whitespace = byte in WHITESPACES_AS_BYTES
if operator_start is None and not is_whitespace:
# Interpret all other non-whitespace characters as the start of an operation.
operator_start = index
if operator_start is not None and is_whitespace:
# A whitespace stops an operation.
# Assume that having an inline image with tons of whitespace is rather unlikely.
operator_end = index
break
if operator_start is None:
# Inline images should not have tons of whitespaces, which would lead to no operator start.
return False
if operator_end is None:
# We probably are inside an operation.
operator_end = length
operator_length = operator_end - operator_start
operator = data[operator_start:operator_end]
if operator.startswith(b"/") and operator_length > 1:
# Name object.
return False
if operator.replace(b".", b"").isdigit():
# Graphics operator, for example a move. A number (integer or float).
return False
if operator_length > 3: # noqa: SIM103
# Usually, the operators inside a content stream should not have more than three characters,
# especially after an inline image.
return True
return False
+730
View File
@@ -0,0 +1,730 @@
"""Functions to convert an image XObject to an image"""
import sys
from io import BytesIO
from typing import Any, Literal, Optional, Union, cast
from .._configuration import get_configuration
from .._utils import check_if_whitespace_only, logger_warning
from ..constants import ColorSpaces, ImageAttributes, StreamAttributes
from ..constants import FilterTypes as FT
from ..errors import EmptyImageDataError, LimitReachedError, PdfReadError
from ..generic import (
ArrayObject,
DecodedStreamObject,
EncodedStreamObject,
NullObject,
TextStringObject,
is_null_or_none,
)
if sys.version_info[:2] >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias
try:
from PIL import Image, UnidentifiedImageError
except ImportError:
raise ImportError(
"pillow is required to do image extraction. "
"It can be installed via 'pip install pypdf[image]'"
)
mode_str_type: TypeAlias = Literal[
"", "1", "RGB", "2bits", "4bits", "P", "L", "RGBA", "CMYK"
]
MAX_IMAGE_MODE_NESTING_DEPTH: int = 10
def _get_image_mode(
color_space: Union[str, list[Any], Any],
color_components: int,
prev_mode: mode_str_type,
depth: int = 0,
) -> tuple[mode_str_type, bool]:
"""
Returns:
Image mode, not taking into account mask (transparency).
ColorInversion is required (like for some DeviceCMYK).
"""
if depth > MAX_IMAGE_MODE_NESTING_DEPTH:
raise PdfReadError(
"Color spaces nested too deeply. If required, consider increasing MAX_IMAGE_MODE_NESTING_DEPTH."
)
if is_null_or_none(color_space):
return "", False
color_space_str: str = ""
if isinstance(color_space, str):
color_space_str = color_space
elif not isinstance(color_space, list):
raise PdfReadError(
"Cannot interpret color space", color_space
) # pragma: no cover
elif not color_space:
return "", False
elif color_space[0].startswith("/Cal"): # /CalRGB or /CalGray
color_space_str = "/Device" + color_space[0][4:]
elif color_space[0] == "/ICCBased":
icc_profile = color_space[1].get_object()
color_components = cast(int, icc_profile["/N"])
color_space_str = icc_profile.get("/Alternate", "")
elif color_space[0] == "/Indexed":
color_space_str = color_space[1].get_object()
mode, invert_color = _get_image_mode(
color_space_str, color_components, prev_mode, depth + 1
)
if mode in ("RGB", "CMYK"):
mode = "P"
return mode, invert_color
elif color_space[0] == "/Separation":
color_space_str = color_space[2].get_object()
mode, invert_color = _get_image_mode(
color_space_str, color_components, prev_mode, depth + 1
)
return mode, True
elif color_space[0] == "/DeviceN":
original_color_space = color_space
color_components = len(color_space[1])
color_space_str = color_space[2].get_object()
if color_space_str == "/DeviceCMYK" and color_components == 1:
if original_color_space[1][0] != "/Black":
logger_warning(
"Color %(color)s converted to Gray. Please share PDF with pypdf dev team",
source=__name__,
color=original_color_space[1][0],
)
return "L", True
mode, invert_color = _get_image_mode(
color_space_str, color_components, prev_mode, depth + 1
)
return mode, invert_color
mode_map: dict[str, mode_str_type] = {
"1bit": "1", # must be zeroth position: color_components may index the values
"/DeviceGray": "L", # must be first position: color_components may index the values
"palette": "P", # must be second position: color_components may index the values
"/DeviceRGB": "RGB", # must be third position: color_components may index the values
"/DeviceCMYK": "CMYK", # must be fourth position: color_components may index the values
"2bit": "2bits",
"4bit": "4bits",
}
mode_values = list(mode_map.values())
mode = (
mode_map.get(color_space_str)
or (
mode_values[color_components]
if 0 <= color_components < len(mode_values)
else None
)
or prev_mode
)
return mode, mode == "CMYK"
def bits2byte(
data: bytes,
size: tuple[int, int],
bits: int,
colors: int = 1,
scale: bool = False,
) -> bytes:
configuration = get_configuration()
# Number of samples per row = pixels per row * components per pixel.
samples_per_row = size[0] * colors
buffer_size = samples_per_row * size[1]
if buffer_size > configuration.image_maximum_buffer_size:
raise LimitReachedError(
f"Requested buffer size {buffer_size} exceeds limit of {configuration.image_maximum_buffer_size}."
)
byte_buffer = bytearray(buffer_size)
mask = (1 << bits) - 1
# Scale a b-bit sample up to the full 0-255 range (e.g. 4-bit 15 -> 255)
# when the output is consumed directly as color (RGB) rather than as a
# palette index.
factor = 255 // mask if scale and mask else 1
required = size[1] * ((samples_per_row * bits + 7) // 8)
if (length := len(data)) < required:
logger_warning("Image data is not rectangular. Adding padding.", source=__name__)
data += b"\x00" * (required - length)
data_index = 0
bit = 8 - bits
for y in range(size[1]):
if bit != 8 - bits:
data_index += 1
bit = 8 - bits
for x in range(samples_per_row):
byte_buffer[x + y * samples_per_row] = (
((data[data_index] >> bit) & mask) * factor
)
bit -= bits
if bit < 0:
data_index += 1
bit = 8 - bits
return bytes(byte_buffer)
def _expand_low_bit_samples(
mode: mode_str_type,
size: tuple[int, int],
data: bytes,
color_space: Union[str, ArrayObject],
) -> tuple[mode_str_type, bytes]:
"""
Expand 2- or 4-bit-per-component samples to one byte per component.
Pillow has no mode for sub-byte samples, so the packed data has to be
unpacked before an image can be built from it. For a single-component
color space the samples are palette/grayscale indices ("P"). For a
multi-component space such as /DeviceRGB they are interleaved color
components, so they are also scaled to the full 0-255 range and become
an "RGB" image.
Returns the resulting mode and data, unchanged when the mode is not a
low-bit one.
"""
if mode not in ("2bits", "4bits"):
return mode, data
bits = int(mode[0])
if color_space == "/DeviceRGB":
return "RGB", bits2byte(data, size, bits, colors=3, scale=True)
return "P", bits2byte(data, size, bits)
def _image_from_bytes(
mode: str, size: tuple[int, int], data: bytes
) -> Image.Image:
pixel_count = size[0] * size[1]
bytes_per_pixel = len(mode)
required_byte_count = pixel_count * bytes_per_pixel
configuration = get_configuration()
if required_byte_count > configuration.image_maximum_buffer_size:
raise LimitReachedError(
f"Requested image buffer size {required_byte_count} exceeds limit "
f"{configuration.image_maximum_buffer_size}."
)
try:
img = Image.frombytes(mode, size, data)
except ValueError as exc:
data_length = len(data)
if data_length == 0:
raise EmptyImageDataError(
"Data is 0 bytes, cannot process an image from empty data."
) from exc
if data_length % pixel_count != 0:
raise
k = required_byte_count / data_length
data = b"".join(bytes((x,) * int(k)) for x in data)
img = Image.frombytes(mode, size, data)
return img
def __handle_flate__indexed(color_space: ArrayObject) -> tuple[Any, Any, Any, Any]:
count = len(color_space)
if count == 4:
color_space, base, hival, lookup = (value.get_object() for value in color_space)
return color_space, base, hival, lookup
# Deal with strange AutoDesk files where `base` and `hival` look like this:
# /DeviceRGB\x00255
element1 = color_space[1]
element1 = element1 if isinstance(element1, str) else element1.get_object()
if count == 3 and "\x00" in element1:
color_space, lookup = color_space[0].get_object(), color_space[2].get_object()
base, hival = element1.split("\x00")
hival = int(hival)
return color_space, base, hival, lookup
raise PdfReadError(f"Expected color space with 4 values, got {count}: {color_space}")
def _handle_flate(
size: tuple[int, int],
data: bytes,
mode: mode_str_type,
color_space: Union[str, ArrayObject],
colors: int,
obj_as_text: str,
) -> tuple[Image.Image, str, str, bool]:
"""
Process image encoded using the zlib/deflate compression method, corresponds to the FlateDecode filter
Returns img, image_format, extension, color inversion
"""
base: Any
hival: Any
lookup: Any
if isinstance(color_space, ArrayObject) and color_space[0] == "/Indexed":
color_space, base, hival, lookup = __handle_flate__indexed(color_space)
mode, data = _expand_low_bit_samples(mode, size, data, color_space)
img = _image_from_bytes(mode, size, data)
if color_space == "/Indexed":
if isinstance(lookup, (EncodedStreamObject, DecodedStreamObject)):
lookup = lookup.get_data()
if isinstance(lookup, TextStringObject):
lookup = lookup.original_bytes
if isinstance(lookup, str):
lookup = lookup.encode()
try:
nb, conv, mode = { # type: ignore[assignment]
"1": (0, "", ""),
"L": (1, "P", "L"),
"P": (0, "", ""),
"RGB": (3, "P", "RGB"),
"CMYK": (4, "P", "CMYK"),
}[_get_image_mode(base, 0, "")[0]]
except KeyError: # pragma: no cover
logger_warning(
"Base %(base)s not coded. Please share PDF with pypdf dev team",
source=__name__,
base=base,
)
lookup = None
else:
if img.mode == "1" and nb == 0:
# A two-color lookup needs at least one byte per color, but the
# base color space resolves to a 1-bit or palette image.
logger_warning(
"Cannot apply lookup for base %(base)s to image with mode 1. "
"Please share PDF with pypdf dev team",
source=__name__,
base=base,
)
elif img.mode == "1":
# Two values ("high" and "low").
expected_count = 2 * nb
actual_count = len(lookup)
if actual_count != expected_count:
if actual_count < expected_count:
logger_warning(
"Not enough lookup values: Expected %(expected_count)d, got %(actual_count)d.",
source=__name__,
expected_count=expected_count,
actual_count=actual_count,
)
lookup += bytes([0] * (expected_count - actual_count))
elif not check_if_whitespace_only(lookup[expected_count:]):
logger_warning(
"Too many lookup values: Expected %(expected_count)d, got %(actual_count)d.",
source=__name__,
expected_count=expected_count,
actual_count=actual_count,
)
lookup = lookup[:expected_count]
colors_arr = [lookup[:nb], lookup[nb:]]
source = img.convert("L")
bands = [
source.point(
[low_value] * 128 + [high_value] * 128
)
for low_value, high_value in zip(colors_arr[0], colors_arr[1])
]
img = bands[0] if nb == 1 else Image.merge(mode, bands)
else:
img = img.convert(conv)
if len(lookup) != (hival + 1) * nb:
logger_warning("Invalid Lookup Table in %(obj_as_text)s", source=__name__, obj_as_text=obj_as_text)
lookup = None
elif mode == "L":
# gray lookup does not work: it is converted to a similar RGB lookup
lookup = b"".join([bytes([b, b, b]) for b in lookup])
mode = "RGB"
# TODO: https://github.com/py-pdf/pypdf/pull/2039
# this is a work around until PIL is able to process CMYK images
elif mode == "CMYK":
_rgb = []
for _c, _m, _y, _k in (
lookup[n : n + 4] for n in range(0, 4 * (len(lookup) // 4), 4)
):
_r = int(255 * (1 - _c / 255) * (1 - _k / 255))
_g = int(255 * (1 - _m / 255) * (1 - _k / 255))
_b = int(255 * (1 - _y / 255) * (1 - _k / 255))
_rgb.append(bytes((_r, _g, _b)))
lookup = b"".join(_rgb)
mode = "RGB"
if lookup is not None:
img.putpalette(lookup, rawmode=mode)
img = img.convert("L" if base == ColorSpaces.DEVICE_GRAY else "RGB")
elif not is_null_or_none(color_space) and color_space[0] == "/ICCBased":
# Exclude pure black-and-white images.
# TODO: The remaining code still does not look correct. Shouldn't the proper way be
# to use the original image and apply the ICC transformation on it?
# For now, this just loads the original image with a different color space.
if mode != "1":
# Table 65 - Additional Entries Specific to an ICC Profile Stream Dictionary
mode2 = _get_image_mode(color_space, colors, mode)[0]
if mode != mode2:
img = Image.frombytes(mode, size, data) # reloaded as mode may have changed
if mode == "CMYK":
image_format = "TIFF"
extension = ".tif"
else:
image_format = "PNG"
extension = ".png" # mime_type: "image/png"
return img, image_format, extension, False
def _handle_jpx(
size: tuple[int, int],
data: bytes,
mode: mode_str_type,
color_space: str,
colors: int,
) -> tuple[Image.Image, str, str, bool]:
"""
Process image encoded as JPX/JPEG2000
Returns img, image_format, extension, inversion
"""
img1: Image.Image = Image.open(BytesIO(data), formats=("JPEG2000",))
mode, invert_color = _get_image_mode(color_space, colors, mode)
if mode == "":
mode = cast(mode_str_type, img1.mode)
invert_color = mode == "CMYK"
if img1.mode == "RGBA" and mode == "RGB":
mode = "RGBA"
# we need to convert to the good mode
if img1.mode == mode or {img1.mode, mode} == {"L", "P"}: # compare (unordered) sets
# L and P are indexed modes which should not be changed.
img = img1
elif {img1.mode, mode} == {"RGBA", "CMYK"}:
# RGBA / CMYK are 4bytes encoding where
# the encoding should be corrected
img = Image.frombytes(mode, img1.size, img1.tobytes())
else: # pragma: no cover
img = img1.convert(mode)
# CMYK conversion
# https://stackverflow.com/questions/38855022/
if img.mode == "CMYK" and color_space == "/ICCBased":
img = img.convert("RGB")
return img, "JPEG2000", ".jp2", invert_color
def _apply_decode(
img: Image.Image,
x_object: dict[str, Any],
lfilters: FT,
color_space: Union[str, list[Any], Any],
invert_color: bool,
) -> Image.Image:
# CMYK image and other color spaces without decode
# requires reverting scale (cf p243,2§ last sentence)
if ImageAttributes.DECODE in x_object:
decode = x_object[ImageAttributes.DECODE]
if img.mode == "CMYK" and lfilters == FT.DCT_DECODE:
# Adobe writes CMYK JPEGs with inverted component values, which is
# what the [min max] -> [1 0] remap in the branches below
# compensates for. An explicit /Decode array is expressed in terms
# of the true values, so it has to be combined with that inversion
# rather than replacing it - otherwise an identity /Decode leaves
# the image inverted (#2931).
#
# Substituting s -> 1-s into dmin + s*(dmax-dmin) gives
# dmax + s*(dmin-dmax), which is the same pair swapped. A malformed
# odd-length array is left untouched here and dropped with a
# warning further down.
decode = [
value
for index in range(0, len(decode) - 1, 2)
for value in (decode[index + 1], decode[index])
]
elif img.mode == "CMYK" and lfilters == FT.JPX_DECODE:
decode = [1.0, 0.0] if not invert_color else [0.0, 1.0]
decode = decode * len(img.getbands())
elif (img.mode == "CMYK" and lfilters == FT.DCT_DECODE) or (invert_color and img.mode == "L"):
decode = [1.0, 0.0] * len(img.getbands())
else:
decode = None
if (
isinstance(color_space, ArrayObject)
and color_space[0].get_object() == "/Indexed"
):
decode = None # decode is meaningless if Indexed
if (
isinstance(color_space, ArrayObject)
and color_space[0].get_object() == "/Separation"
):
decode = [1.0, 0.0] * len(img.getbands())
if decode is not None and (
not isinstance(decode, list) or len(decode) % 2 != 0
):
# /Decode is read straight from the image dictionary and must hold an
# even number of values (a [min max] pair per component). A malformed
# array would otherwise read past its end at decode[i + 1]; drop it and
# render the image without the remap.
logger_warning(
"Ignoring malformed /Decode array %(decode)s; expected an even number of values.",
source=__name__,
decode=decode,
)
decode = None
if decode is not None and not all(decode[i] == i % 2 for i in range(len(decode))):
lut: list[int] = []
for i in range(0, len(decode), 2):
dmin = decode[i]
dmax = decode[i + 1]
lut.extend(
round(255.0 * (j / 255.0 * (dmax - dmin) + dmin)) for j in range(256)
)
img = img.point(lut)
return img
def _get_mode_and_invert_color(
x_object: dict[str, Any], colors: int, color_space: Union[str, list[Any], Any]
) -> tuple[mode_str_type, bool]:
if (
ImageAttributes.COLOR_SPACE in x_object
and x_object[ImageAttributes.COLOR_SPACE] == ColorSpaces.DEVICE_RGB
):
# https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
mode: mode_str_type = "RGB"
if x_object.get("/BitsPerComponent", 8) < 8:
mode, invert_color = _get_image_mode(
f"{x_object.get('/BitsPerComponent', 8)}bit", 0, ""
)
else:
mode, invert_color = _get_image_mode(
color_space,
2
if (
colors == 1
and (
not is_null_or_none(color_space)
and "Gray" not in color_space
)
)
else colors,
"",
)
return mode, invert_color
def _apply_alpha(
*,
img: Image.Image,
x_object: dict[str, Any],
obj_as_text: str,
image_format: str,
extension: str,
visited: set[int],
) -> tuple[Image.Image, str, str]:
if ImageAttributes.S_MASK not in x_object:
return img, extension, image_format
s_mask = x_object[ImageAttributes.S_MASK]
if id(s_mask) in visited:
# A soft mask that refers back to an image already being
# converted would recurse until the interpreter runs out of
# stack. Such a chain cannot describe a real alpha channel, so
# drop the mask and keep the image we have.
logger_warning(
"Ignoring cyclic /SMask reference in %(obj_as_text)s",
source=__name__,
obj_as_text=obj_as_text,
)
return img, extension, image_format
alpha = _xobj_to_image(s_mask, visited=visited)[2]
if img.size != alpha.size:
logger_warning(
"Image and mask size not matching: %(image_size)s vs. %(alpha_size)s %(obj_as_text)s",
source=__name__,
image_size=img.size,
alpha_size=alpha.size,
obj_as_text=obj_as_text,
)
else:
# TODO: implement mask
if alpha.mode != "L":
alpha = alpha.convert("L")
if img.mode == "P":
img = img.convert("RGB")
elif img.mode == "1":
img = img.convert("L")
img.putalpha(alpha)
if "JPEG" in image_format:
image_format = "JPEG2000"
extension = ".jp2"
else:
image_format = "PNG"
extension = ".png"
return img, extension, image_format
def _xobj_to_image(
x_object: dict[str, Any],
pillow_parameters: Union[dict[str, Any], None] = None,
visited: Optional[set[int]] = None,
) -> tuple[Optional[str], bytes, Any]:
"""
Users need to have the pillow package installed.
It's unclear if pypdf will keep this function here, hence it's private.
It might get removed at any point.
Args:
x_object:
pillow_parameters: parameters provided to Pillow Image.save() method,
cf. <https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.save>
visited: Set of id() values of XObjects already being converted higher
up the /SMask chain, used to detect cyclic soft masks.
Returns:
Tuple[file extension, bytes, PIL.Image.Image]
"""
if visited is None:
visited = set()
visited.add(id(x_object))
# For error reporting
obj_as_text = (
x_object.indirect_reference.__repr__()
if x_object is None # pragma: no cover
else x_object.__repr__()
)
# Get size and data
size = (cast(int, x_object[ImageAttributes.WIDTH]), cast(int, x_object[ImageAttributes.HEIGHT]))
data = x_object.get_data() # type: ignore[attr-defined]
if isinstance(data, str): # pragma: no cover
data = data.encode()
if len(data) % (size[0] * size[1]) == 1 and data[-1] == 0x0A: # i.e. '\n'
data = data[:-1]
# Get color properties
colors = x_object.get("/Colors", 1)
color_space: Any = x_object.get("/ColorSpace", NullObject()).get_object()
if isinstance(color_space, list) and len(color_space) == 1:
color_space = color_space[0].get_object()
mode, invert_color = _get_mode_and_invert_color(x_object, colors, color_space)
# Get filters
filters = x_object.get(StreamAttributes.FILTER, NullObject()).get_object()
# An empty array is a valid way of saying that no filter is applied: treat it
# like a missing /Filter entry rather than raising IndexError on the lookup.
last_filter = filters[-1] if filters and isinstance(filters, list) else filters
decode_parms = x_object.get(StreamAttributes.DECODE_PARMS)
if decode_parms and isinstance(decode_parms, (tuple, list)):
decode_parms = decode_parms[0]
else:
decode_parms = {}
if not isinstance(decode_parms, dict):
decode_parms = {}
extension = None
if last_filter in (FT.FLATE_DECODE, FT.RUN_LENGTH_DECODE):
img, image_format, extension, _ = _handle_flate(
size,
data,
mode,
color_space,
colors,
obj_as_text,
)
elif last_filter in (FT.LZW_DECODE, FT.ASCII_85_DECODE):
# I'm not sure if the following logic is correct.
# There might not be any relationship between the filter and the
# extension
if last_filter == FT.LZW_DECODE:
image_format = "TIFF"
extension = ".tiff" # mime_type = "image/tiff"
else:
image_format = "PNG"
extension = ".png" # mime_type = "image/png"
try:
img = Image.open(BytesIO(data), formats=("TIFF", "PNG"))
except UnidentifiedImageError:
fallback_mode, fallback_data = _expand_low_bit_samples(
mode, size, data, color_space
)
img = _image_from_bytes(fallback_mode, size, fallback_data)
elif last_filter == FT.DCT_DECODE:
img, image_format, extension = Image.open(BytesIO(data)), "JPEG", ".jpg"
# invert_color kept unchanged
elif last_filter == FT.JPX_DECODE:
img, image_format, extension, invert_color = _handle_jpx(
size, data, mode, color_space, colors
)
elif last_filter == FT.CCITT_FAX_DECODE:
img, image_format, extension, invert_color = (
Image.open(BytesIO(data), formats=("TIFF",)),
"TIFF",
".tiff",
False,
)
elif last_filter == FT.JBIG2_DECODE:
img, image_format, extension, invert_color = (
Image.open(BytesIO(data), formats=("PNG", "PPM")),
"PNG",
".png",
False,
)
elif mode == "CMYK":
img, image_format, extension, invert_color = (
_image_from_bytes(mode, size, data),
"TIFF",
".tif",
False,
)
elif mode == "":
raise PdfReadError(f"ColorSpace field not found in {x_object}")
else:
# Images without a filter (for example inline images) reach Pillow
# directly, so low-bit samples have to be expanded here as well.
mode, data = _expand_low_bit_samples(mode, size, data, color_space)
img, image_format, extension, invert_color = (
_image_from_bytes(mode, size, data),
"PNG",
".png",
False,
)
img = _apply_decode(img, x_object, last_filter, color_space, invert_color)
img, extension, image_format = _apply_alpha(
img=img, x_object=x_object, obj_as_text=obj_as_text, image_format=image_format, extension=extension,
visited=visited,
)
if pillow_parameters is None:
pillow_parameters = {}
# Preserve JPEG image quality - see issue #3515.
if image_format == "JPEG":
# This prevents: Cannot use 'keep' when original image is not a JPEG:
# "JPEG" is the value of PIL.JpegImagePlugin.JpegImageFile.format
img.format = "JPEG"
if "quality" not in pillow_parameters:
pillow_parameters["quality"] = "keep"
# Save image to bytes
img_byte_arr = BytesIO()
try:
img.save(img_byte_arr, format=image_format, **pillow_parameters)
except OSError: # pragma: no cover # covered with pillow 10.3
# in case of we convert to RGBA and then to PNG
img1 = img.convert("RGBA")
image_format = "PNG"
extension = ".png"
img_byte_arr = BytesIO()
img1.save(img_byte_arr, format=image_format)
data = img_byte_arr.getvalue()
try: # temporary try/except until other fixes of images
img = Image.open(BytesIO(data))
except Exception as exception:
logger_warning("Failed loading image: %(exception)s", source=__name__, exception=exception)
img = None # type: ignore[assignment,unused-ignore] # TODO: Remove unused-ignore on Python 3.10
return extension, data, img
+150
View File
@@ -0,0 +1,150 @@
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# This module contains code used by _writer.py to track links in pages
# being added to the writer until the links can be resolved.
from typing import TYPE_CHECKING, Optional, Union, cast
from .._utils import logger_warning
from . import ArrayObject, DictionaryObject, IndirectObject, PdfObject, TextStringObject, is_null_or_none
if TYPE_CHECKING:
from .._page import PageObject
from .._reader import PdfReader
from .._writer import PdfWriter
class NamedReferenceLink:
"""Named reference link being preserved until we can resolve it correctly."""
def __init__(self, reference: TextStringObject, source_pdf: "PdfReader") -> None:
"""reference: TextStringObject with named reference"""
self._reference = reference
self._source_pdf = source_pdf
def find_referenced_page(self) -> Union[IndirectObject, None]:
destination = self._source_pdf.named_destinations.get(str(self._reference))
return destination.page if destination else None
def patch_reference(self, target_pdf: "PdfWriter", new_page: IndirectObject) -> None:
"""target_pdf: PdfWriter which the new link went into"""
# point named destination in new PDF to the new page
if str(self._reference) not in target_pdf.named_destinations:
target_pdf.add_named_destination(str(self._reference), new_page.page_number)
class DirectReferenceLink:
"""Direct reference link being preserved until we can resolve it correctly."""
def __init__(self, reference: ArrayObject) -> None:
"""reference: an ArrayObject whose first element is the Page indirect object"""
self._reference = reference
def find_referenced_page(self) -> Union[IndirectObject, None]:
if not self._reference:
return None
return cast(IndirectObject, self._reference[0])
def patch_reference(self, target_pdf: "PdfWriter", new_page: IndirectObject) -> None:
"""target_pdf: PdfWriter which the new link went into"""
self._reference[0] = new_page
ReferenceLink = Union[NamedReferenceLink, DirectReferenceLink]
def extract_links(new_page: "PageObject", old_page: "PageObject") -> list[tuple[ReferenceLink, ReferenceLink]]:
"""Extracts links from two pages on the assumption that the two pages are
the same. Produces one list of (new link, old link) tuples.
Non-link annotations are ignored before pairing to avoid dropping valid
links when one page includes additional non-link annotation entries.
"""
new_annotations = new_page.get("/Annots", ArrayObject()).get_object()
old_annotations = old_page.get("/Annots", ArrayObject()).get_object()
if is_null_or_none(new_annotations):
new_annotations = ArrayObject()
if is_null_or_none(old_annotations):
old_annotations = ArrayObject()
if not isinstance(new_annotations, ArrayObject) or not isinstance(old_annotations, ArrayObject):
logger_warning(
"Expected annotation arrays: %(old_annotations)s %(new_annotations)s. Ignoring annotations.",
source=__name__,
old_annotations=old_annotations,
new_annotations=new_annotations,
)
return []
new_links = [
link
for annotation in new_annotations
if (link := _build_link(annotation, new_page)) is not None
]
old_links = [
link
for annotation in old_annotations
if (link := _build_link(annotation, old_page)) is not None
]
if len(new_links) != len(old_links):
logger_warning(
"Annotation sizes differ: %(old_count)d vs. %(new_count)d",
source=__name__,
old_count=len(old_links),
new_count=len(new_links),
)
return list(zip(new_links, old_links))
def _build_link(indirect_object: IndirectObject, page: "PageObject") -> Optional[ReferenceLink]:
src = cast("PdfReader", page.pdf)
link = cast(DictionaryObject, indirect_object.get_object())
if (not isinstance(link, DictionaryObject)) or link.get("/Subtype") != "/Link":
return None
if "/A" in link:
action = cast(DictionaryObject, link["/A"])
if action.get("/S") != "/GoTo":
return None
if "/D" not in action:
return None
return _create_link(action["/D"], src)
if "/Dest" in link:
return _create_link(link["/Dest"], src)
return None # Nothing to do here
def _create_link(reference: PdfObject, source_pdf: "PdfReader") -> Optional[ReferenceLink]:
if isinstance(reference, TextStringObject):
return NamedReferenceLink(reference, source_pdf)
if isinstance(reference, ArrayObject):
return DirectReferenceLink(reference)
return None
+33
View File
@@ -0,0 +1,33 @@
from typing import Union
from .._utils import StreamType, deprecation_no_replacement
from ._base import NameObject
from ._data_structures import Destination
class OutlineItem(Destination):
def write_to_stream(
self, stream: StreamType, encryption_key: Union[str, bytes, None] = None
) -> None:
if encryption_key is not None: # deprecated
deprecation_no_replacement(
"the encryption_key parameter of write_to_stream", "5.0.0"
)
stream.write(b"<<\n")
for key in [
NameObject(x)
for x in ["/Title", "/Parent", "/First", "/Last", "/Next", "/Prev"]
if x in self
]:
key.write_to_stream(stream)
stream.write(b" ")
value = self.raw_get(key)
value.write_to_stream(stream)
stream.write(b"\n")
key = NameObject("/Dest")
key.write_to_stream(stream)
stream.write(b" ")
value = self.dest_array
value.write_to_stream(stream)
stream.write(b"\n")
stream.write(b">>")
+135
View File
@@ -0,0 +1,135 @@
from collections.abc import Sequence
from typing import Any, Union
from ._base import FloatObject, NumberObject
from ._data_structures import ArrayObject
class RectangleObject(ArrayObject):
"""
This class is used to represent *page boxes* in pypdf.
These boxes include:
* :attr:`artbox <pypdf._page.PageObject.artbox>`
* :attr:`bleedbox <pypdf._page.PageObject.bleedbox>`
* :attr:`cropbox <pypdf._page.PageObject.cropbox>`
* :attr:`mediabox <pypdf._page.PageObject.mediabox>`
* :attr:`trimbox <pypdf._page.PageObject.trimbox>`
"""
def __init__(self, arr: Sequence[Any]) -> None:
# must have four points
assert len(arr) == 4
# automatically convert arr[x] into NumberObject(arr[x]) if necessary
ArrayObject.__init__(self, [self._ensure_is_number(x) for x in arr])
def _ensure_is_number(self, value: Any) -> Union[FloatObject, NumberObject]:
if not isinstance(value, (FloatObject, NumberObject)):
return FloatObject(value)
return value
def scale(self, sx: float, sy: float) -> "RectangleObject":
return RectangleObject(
(
float(self.left) * sx,
float(self.bottom) * sy,
float(self.right) * sx,
float(self.top) * sy,
)
)
def __repr__(self) -> str:
return f"RectangleObject({list(self)!r})"
@property
def left(self) -> FloatObject:
value: FloatObject = self[0]
return value
@left.setter
def left(self, f: float) -> None:
self[0] = FloatObject(f)
@property
def bottom(self) -> FloatObject:
value: FloatObject = self[1]
return value
@bottom.setter
def bottom(self, f: float) -> None:
self[1] = FloatObject(f)
@property
def right(self) -> FloatObject:
value: FloatObject = self[2]
return value
@right.setter
def right(self, f: float) -> None:
self[2] = FloatObject(f)
@property
def top(self) -> FloatObject:
value: FloatObject = self[3]
return value
@top.setter
def top(self, f: float) -> None:
self[3] = FloatObject(f)
@property
def lower_left(self) -> tuple[float, float]:
"""
Property to read and modify the lower left coordinate of this box
in (x,y) form.
"""
return self.left, self.bottom
@lower_left.setter
def lower_left(self, value: tuple[float, float]) -> None:
self[0], self[1] = (self._ensure_is_number(x) for x in value)
@property
def lower_right(self) -> tuple[float, float]:
"""
Property to read and modify the lower right coordinate of this box
in (x,y) form.
"""
return self.right, self.bottom
@lower_right.setter
def lower_right(self, value: tuple[float, float]) -> None:
self[2], self[1] = (self._ensure_is_number(x) for x in value)
@property
def upper_left(self) -> tuple[float, float]:
"""
Property to read and modify the upper left coordinate of this box
in (x,y) form.
"""
return self.left, self.top
@upper_left.setter
def upper_left(self, value: tuple[float, float]) -> None:
self[0], self[3] = (self._ensure_is_number(x) for x in value)
@property
def upper_right(self) -> tuple[float, float]:
"""
Property to read and modify the upper right coordinate of this box
in (x,y) form.
"""
return self.right, self.top
@upper_right.setter
def upper_right(self, value: tuple[float, float]) -> None:
self[2], self[3] = (self._ensure_is_number(x) for x in value)
@property
def width(self) -> float:
return self.right - self.left
@property
def height(self) -> float:
return self.top - self.bottom
+225
View File
@@ -0,0 +1,225 @@
import codecs
from typing import Union
from .._codecs import _pdfdoc_encoding
from .._utils import StreamType, logger_warning, read_non_whitespace
from ..errors import STREAM_TRUNCATED_PREMATURELY, PdfStreamError
from ._base import ByteStringObject, TextStringObject
def hex_to_rgb(value: str) -> tuple[float, float, float]:
return tuple(int(value.lstrip("#")[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # type: ignore[return-value]
def read_hex_string_from_stream(
stream: StreamType,
forced_encoding: Union[str, list[str], dict[int, str], None] = None,
) -> Union["TextStringObject", "ByteStringObject"]:
stream.read(1)
arr = bytearray()
x = b""
while True:
tok = read_non_whitespace(stream)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok == b">":
break
if tok not in b"0123456789abcdefABCDEF":
raise PdfStreamError(
f"Invalid hexadecimal character {tok!r} in hex string"
)
x += tok
if len(x) == 2:
arr.append(int(x, base=16))
x = b""
if len(x) == 1:
x += b"0"
if x != b"":
arr.append(int(x, base=16))
return create_string_object(bytes(arr), forced_encoding)
__ESCAPE_DICT__ = {
b"n": ord(b"\n"),
b"r": ord(b"\r"),
b"t": ord(b"\t"),
b"b": ord(b"\b"),
b"f": ord(b"\f"),
b"(": ord(b"("),
b")": ord(b")"),
b"/": ord(b"/"),
b"\\": ord(b"\\"),
b" ": ord(b" "),
b"%": ord(b"%"),
b"<": ord(b"<"),
b">": ord(b">"),
b"[": ord(b"["),
b"]": ord(b"]"),
b"#": ord(b"#"),
b"_": ord(b"_"),
b"&": ord(b"&"),
b"$": ord(b"$"),
}
__BACKSLASH_CODE__ = 92
def read_string_from_stream(
stream: StreamType,
forced_encoding: Union[str, list[str], dict[int, str], None] = None,
) -> Union["TextStringObject", "ByteStringObject"]:
tok = stream.read(1)
parens = 1
txt = []
while True:
tok = stream.read(1)
if not tok:
raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
if tok == b"(":
parens += 1
elif tok == b")":
parens -= 1
if parens == 0:
break
elif tok == b"\\":
tok = stream.read(1)
try:
txt.append(__ESCAPE_DICT__[tok])
continue
except KeyError:
if b"0" <= tok <= b"7":
# "The number ddd may consist of one, two, or three
# octal digits; high-order overflow shall be ignored.
# Three octal digits shall be used, with leading zeros
# as needed, if the next character of the string is also
# a digit." (PDF reference 7.3.4.2, p 16)
sav = stream.tell() - 1
for _ in range(2):
ntok = stream.read(1)
if b"0" <= ntok <= b"7":
tok += ntok
else:
stream.seek(-1, 1) # ntok has to be analyzed
break
i = int(tok, base=8)
if i > 255:
txt.append(__BACKSLASH_CODE__)
stream.seek(sav)
else:
txt.append(i)
continue
if tok in b"\n\r":
# This case is hit when a backslash followed by a line
# break occurs. If it's a multi-char EOL, consume the
# second character:
tok = stream.read(1)
if tok not in b"\n\r":
stream.seek(-1, 1)
# Then don't add anything to the actual string, since this
# line break was escaped:
continue
logger_warning(
"Unexpected escaped string: %(token)s",
source=__name__,
token=tok.decode("utf-8", "ignore"),
)
txt.append(__BACKSLASH_CODE__)
txt.append(ord(tok))
return create_string_object(bytes(txt), forced_encoding)
def create_string_object(
string: Union[str, bytes],
forced_encoding: Union[str, list[str], dict[int, str], None] = None,
) -> Union[TextStringObject, ByteStringObject]:
"""
Create a ByteStringObject or a TextStringObject from a string to represent the string.
Args:
string: The data being used
forced_encoding: Typically None, or an encoding string
Returns:
A ByteStringObject
Raises:
TypeError: If string is not of type str or bytes.
"""
if isinstance(string, str):
return TextStringObject(string)
if isinstance(string, bytes):
if isinstance(forced_encoding, (list, dict)):
out = []
if isinstance(forced_encoding, list):
list_length = len(forced_encoding)
else:
list_length = None
for x in string:
if (
(list_length is not None and x < list_length) or
(list_length is None and x in forced_encoding)
):
value = forced_encoding[x]
else:
value = bytes((x,)).decode("charmap")
out.append(value)
obj = TextStringObject("".join(out))
obj._original_bytes = string
return obj
if isinstance(forced_encoding, str):
if forced_encoding == "bytes":
return ByteStringObject(string)
obj = TextStringObject(string.decode(forced_encoding))
obj._original_bytes = string
return obj
try:
if string.startswith((codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE)):
retval = TextStringObject(string.decode("utf-16"))
retval._original_bytes = string
retval.autodetect_utf16 = True
retval.utf16_bom = string[:2]
return retval
if string.startswith(b"\x00"):
retval = TextStringObject(string.decode("utf-16be"))
retval._original_bytes = string
retval.autodetect_utf16 = True
retval.utf16_bom = codecs.BOM_UTF16_BE
return retval
if string[1:2] == b"\x00":
retval = TextStringObject(string.decode("utf-16le"))
retval._original_bytes = string
retval.autodetect_utf16 = True
retval.utf16_bom = codecs.BOM_UTF16_LE
return retval
# This is probably a big performance hit here, but we need
# to convert string objects into the text/unicode-aware
# version if possible... and the only way to check if that's
# possible is to try.
# Some strings are strings, some are just byte arrays.
retval = TextStringObject(decode_pdfdocencoding(string))
retval._original_bytes = string
retval.autodetect_pdfdocencoding = True
return retval
except UnicodeDecodeError:
return ByteStringObject(string)
else:
raise TypeError("create_string_object should have str or unicode arg")
def decode_pdfdocencoding(byte_array: bytes) -> str:
retval = ""
for b in byte_array:
c = _pdfdoc_encoding[b]
if c == "\u0000":
raise UnicodeDecodeError(
"pdfdocencoding",
bytearray(b),
-1,
-1,
"does not exist in translation table",
)
retval += c
return retval
+185
View File
@@ -0,0 +1,185 @@
# Copyright (c) 2023, Pubpub-ZZ
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from typing import (
Any,
Optional,
cast,
)
from ..constants import PageAttributes
from ._base import BooleanObject, NameObject, NumberObject, is_null_or_none
from ._data_structures import ArrayObject, DictionaryObject
f_obj = BooleanObject(False)
#: The page boundaries a viewer preference may name, per Table 147 in the 2.0
#: reference.
BOX_NAMES = [
PageAttributes.MEDIABOX,
PageAttributes.CROPBOX,
PageAttributes.BLEEDBOX,
PageAttributes.TRIMBOX,
PageAttributes.ARTBOX,
]
class ViewerPreferences(DictionaryObject):
def __init__(self, obj: Optional[DictionaryObject] = None) -> None:
super().__init__(self)
if not is_null_or_none(obj):
self.update(obj.items()) # type: ignore[union-attr]
try:
self.indirect_reference = obj.indirect_reference # type: ignore[union-attr]
except AttributeError:
pass
def _get_bool(self, key: str, default: Optional[BooleanObject]) -> Optional[BooleanObject]:
return self.get(key, default)
def _set_bool(self, key: str, v: bool) -> None:
self[NameObject(key)] = BooleanObject(v is True)
def _get_name(self, key: str, default: Optional[NameObject]) -> Optional[NameObject]:
return self.get(key, default)
def _set_name(self, key: str, lst: list[str], v: NameObject) -> None:
if v[0] != "/":
raise ValueError(f"{v} does not start with '/'")
if lst != [] and v not in lst:
raise ValueError(f"{v} is an unacceptable value")
self[NameObject(key)] = NameObject(v)
def _get_arr(self, key: str, default: Optional[list[Any]]) -> Optional[ArrayObject]:
return self.get(key, None if default is None else ArrayObject(default))
def _set_arr(self, key: str, v: Optional[ArrayObject]) -> None:
if v is None:
try:
del self[NameObject(key)]
except KeyError:
pass
return
if not isinstance(v, ArrayObject):
raise ValueError("ArrayObject is expected")
if key == "/PrintPageRange" and len(v) % 2:
# The array holds first/last page pairs, so an odd length leaves a
# range without its end.
raise ValueError(
f"/PrintPageRange holds page pairs, got {len(v)} entries: {v}"
)
self[NameObject(key)] = v
def _get_int(self, key: str, default: Optional[NumberObject]) -> Optional[NumberObject]:
return self.get(key, default)
def _set_int(self, key: str, v: int) -> None:
if v < 0:
raise ValueError(f"{v} is an unacceptable value for {key}")
self[NameObject(key)] = NumberObject(v)
@property
def PRINT_SCALING(self) -> NameObject:
return NameObject("/PrintScaling")
def __new__(cls: Any, value: Any = None) -> "ViewerPreferences": # noqa: PYI034
def _add_prop_bool(key: str, default: Optional[BooleanObject]) -> property:
return property(
lambda self: self._get_bool(key, default),
lambda self, v: self._set_bool(key, v),
None,
f"""
Returns/Modify the status of {key}, Returns {default} if not defined
""",
)
def _add_prop_name(
key: str, lst: list[str], default: Optional[NameObject]
) -> property:
return property(
lambda self: self._get_name(key, default),
lambda self, v: self._set_name(key, lst, v),
None,
f"""
Returns/Modify the status of {key}, Returns {default} if not defined.
Acceptable values: {lst}
""",
)
def _add_prop_arr(key: str, default: Optional[ArrayObject]) -> property:
return property(
lambda self: self._get_arr(key, default),
lambda self, v: self._set_arr(key, v),
None,
f"""
Returns/Modify the status of {key}, Returns {default} if not defined
""",
)
def _add_prop_int(key: str, default: Optional[int]) -> property:
return property(
lambda self: self._get_int(key, default),
lambda self, v: self._set_int(key, v),
None,
f"""
Returns/Modify the status of {key}, Returns {default} if not defined
""",
)
cls.hide_toolbar = _add_prop_bool("/HideToolbar", f_obj)
cls.hide_menubar = _add_prop_bool("/HideMenubar", f_obj)
cls.hide_windowui = _add_prop_bool("/HideWindowUI", f_obj)
cls.fit_window = _add_prop_bool("/FitWindow", f_obj)
cls.center_window = _add_prop_bool("/CenterWindow", f_obj)
cls.display_doctitle = _add_prop_bool("/DisplayDocTitle", f_obj)
cls.non_fullscreen_pagemode = _add_prop_name(
"/NonFullScreenPageMode",
["/UseNone", "/UseOutlines", "/UseThumbs", "/UseOC"],
NameObject("/UseNone"),
)
cls.direction = _add_prop_name(
"/Direction", ["/L2R", "/R2L"], NameObject("/L2R")
)
cls.view_area = _add_prop_name("/ViewArea", BOX_NAMES, None)
cls.view_clip = _add_prop_name("/ViewClip", BOX_NAMES, None)
cls.print_area = _add_prop_name("/PrintArea", BOX_NAMES, None)
cls.print_clip = _add_prop_name("/PrintClip", BOX_NAMES, None)
cls.print_scaling = _add_prop_name(
"/PrintScaling", ["/None", "/AppDefault"], None
)
cls.duplex = _add_prop_name(
"/Duplex", ["/Simplex", "/DuplexFlipShortEdge", "/DuplexFlipLongEdge"], None
)
cls.pick_tray_by_pdfsize = _add_prop_bool("/PickTrayByPDFSize", None)
cls.print_pagerange = _add_prop_arr("/PrintPageRange", None)
cls.num_copies = _add_prop_int("/NumCopies", None)
cls.enforce = _add_prop_arr("/Enforce", ArrayObject())
return cast("ViewerPreferences", DictionaryObject.__new__(cls))