258 lines
8.0 KiB
Python
258 lines
8.0 KiB
Python
"""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)
|