
| Current Path : /var/www/wsgi/www/api/venv/lib64/python3.12/site-packages/pyhanko/pdf_utils/ |
Linux ift1.ift-informatik.de 5.4.0-216-generic #236-Ubuntu SMP Fri Apr 11 19:53:21 UTC 2025 x86_64 |
| Current File : /var/www/wsgi/www/api/venv/lib64/python3.12/site-packages/pyhanko/pdf_utils/text.py |
"""Utilities related to text rendering & layout."""
from dataclasses import dataclass, field
from typing import Optional
from pyhanko.config.api import ConfigurableMixin
from pyhanko.config.errors import ConfigurationError
from pyhanko.pdf_utils import layout
from pyhanko.pdf_utils.content import PdfContent, PdfResources, ResourceType
from pyhanko.pdf_utils.font import FontEngineFactory, SimpleFontEngineFactory
from pyhanko.pdf_utils.generic import pdf_name
@dataclass(frozen=True)
class TextStyle(ConfigurableMixin):
"""Container for basic test styling settings."""
font: FontEngineFactory = field(
default_factory=SimpleFontEngineFactory.default_factory
)
"""
The :class:`.FontEngineFactory` to be used for this text style.
Defaults to Courier (as a non-embedded standard font).
"""
font_size: int = 10
"""
Font size to be used.
"""
leading: Optional[int] = None
"""
Text leading. If ``None``, the :attr:`font_size` parameter is used instead.
"""
@classmethod
def process_entries(cls, config_dict):
super().process_entries(config_dict)
try:
fc = config_dict['font']
if not isinstance(fc, str) or not (
fc.endswith('.otf') or fc.endswith('.ttf')
):
raise ConfigurationError(
"'font' must be a path to an OpenType or "
"TrueType font file."
)
from pyhanko.pdf_utils.font.opentype import GlyphAccumulatorFactory
config_dict['font'] = GlyphAccumulatorFactory(fc)
except KeyError:
pass
DEFAULT_BOX_LAYOUT = layout.SimpleBoxLayoutRule(
x_align=layout.AxisAlignment.ALIGN_MID,
y_align=layout.AxisAlignment.ALIGN_MID,
)
DEFAULT_TEXT_BOX_MARGIN = 10
@dataclass(frozen=True)
class TextBoxStyle(TextStyle):
"""Extension of :class:`.TextStyle` for use in text boxes."""
border_width: int = 0
"""
Border width, if applicable.
"""
box_layout_rule: Optional[layout.SimpleBoxLayoutRule] = None
"""
Layout rule to nest the text within its containing box.
.. warning::
This only affects the position of the text object, not the alignment of
the text within.
"""
vertical_text: bool = False
"""
Switch layout code to vertical mode instead of horizontal mode.
"""
class TextBox(PdfContent):
"""Implementation of a text box that implements the :class:`.PdfContent`
interface.
.. note::
Text boxes currently don't offer automatic word wrapping.
"""
def __init__(
self,
style: TextBoxStyle,
writer,
resources: Optional[PdfResources] = None,
box: Optional[layout.BoxConstraints] = None,
font_name='F1',
):
super().__init__(resources, writer=writer, box=box)
self.style = style
self._content = None
self._content_lines = self._wrapped_lines = None
self.font_name = font_name
self.font_engine = style.font.create_font_engine(writer)
self._nat_text_height = self._nat_text_width = 0
def put_string_line(self, txt):
font_engine = self.font_engine
shape_result = font_engine.shape(txt)
x_advance = shape_result.x_advance * self.style.font_size
y_advance = shape_result.y_advance * self.style.font_size
ops = shape_result.graphics_ops
if font_engine.uses_complex_positioning:
# Tm and Tlm will be at the same position, after the last glyph
newline_x = -x_advance
newline_y = -y_advance
# TODO deal with TTB scripts with LTR line order too, like Mongolian
vertical = self.style.vertical_text
leading = self.leading
if vertical:
newline_x -= leading
extent = abs(y_advance)
else:
newline_y -= leading
extent = abs(x_advance)
ops += b' %g %g Td' % (newline_x, newline_y)
else:
ops += b' T*'
extent = abs(x_advance)
return ops, extent
@property
def content_lines(self):
"""
:return:
Text content of the text box, broken up into lines.
"""
return self._content_lines
@property
def content(self):
"""
:return:
The actual text content of the text box.
This is a modifiable property.
In textboxes that don't have a fixed size, setting this property
can cause the text box to be resized.
"""
return self._content
@content.setter
def content(self, content):
# TODO text reflowing logic goes here
# (with option to either scale things, or do word wrapping)
self._content = content
natural_text_width = 0
natural_text_height = 0
leading = self.leading
lines = []
vertical = self.style.vertical_text
for line in content.split('\n'):
wrapped_line, extent = self.put_string_line(line)
rounded_extent = int(round(extent))
if vertical:
natural_text_height = max(natural_text_height, rounded_extent)
natural_text_width += leading
else:
natural_text_width = max(natural_text_width, rounded_extent)
natural_text_height += leading
lines.append(wrapped_line)
self._wrapped_lines = lines
self._content_lines = content.split('\n')
self._nat_text_width = natural_text_width
self._nat_text_height = natural_text_height
@property
def leading(self):
"""
:return:
The effective leading value, i.e. the
:attr:`~.TextStyle.leading` attribute of the associated
:class:`.TextBoxStyle`, or :attr:`~.TextStyle.font_size` if
not specified.
"""
style = self.style
return style.font_size if style.leading is None else style.leading
def render(self):
style = self.style
self.set_resource(
category=ResourceType.FONT,
name=pdf_name('/' + self.font_name),
value=self.font_engine.as_resource(),
)
leading = self.leading
nat_text_width = self._nat_text_width
nat_text_height = self._nat_text_height
vertical = self.style.vertical_text
box_layout = self.style.box_layout_rule
if box_layout is None:
margins = layout.Margins.uniform(DEFAULT_TEXT_BOX_MARGIN)
box_layout = DEFAULT_BOX_LAYOUT.substitute_margins(margins)
positioning = box_layout.fit(self.box, nat_text_width, nat_text_height)
command_stream = []
# draw border before scaling
if style.border_width:
command_stream.append(
b'q %g w 0 0 %g %g re S Q'
% (style.border_width, self.box.width, self.box.height)
)
# reposition cursor
command_stream.append(positioning.as_cm())
command_stream += [
b'BT',
b'/%s %d Tf %d TL'
% (self.font_name.encode('latin1'), style.font_size, leading),
]
# start by moving the cursor to the starting position.
# In horizontal mode, that's the top left, accounting for leading.
# In vertical mode, we need the top right.
if vertical:
text_cursor_start = b'%g %g Td' % (
# V-mode leading/baselining is weird like that---the glyph
# origin is in the middle, so we chop off half of the leading
nat_text_width * positioning.x_scale - leading / 2,
nat_text_height * positioning.y_scale,
)
else:
text_cursor_start = b'0 %g Td' % (
nat_text_height * positioning.y_scale - leading
)
command_stream.append(text_cursor_start)
command_stream.extend(self._wrapped_lines)
command_stream.append(b'ET')
return b' '.join(command_stream)