feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)
This commit is contained in:
1 parent
c38b1dae0a
commit
6dcfd59f73
67 files changed
+49444
-40
No files matched your search
Vendored
+115
@@ -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
@@ -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
|
||||
)
|
||||
Vendored
+968
@@ -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))
|
||||
)
|
||||
Vendored
+85
@@ -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")
|
||||
+1900
File diff suppressed because it is too large.
Load diff
Vendored
+465
@@ -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)
|
||||
Vendored
+175
@@ -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
@@ -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
@@ -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
|
||||
Vendored
+150
@@ -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
|
||||
Vendored
+33
@@ -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
@@ -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
|
||||
Vendored
+225
@@ -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
@@ -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))
|
||||
Reference in new issue
Block a user