895 lines
41 KiB
Python
895 lines
41 KiB
Python
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
|
|
)
|