
| 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/layout.py |
"""Layout utilities (to be expanded)"""
import enum
import logging
from dataclasses import dataclass
from fractions import Fraction
from typing import Optional, Union
from pyhanko.config.api import ConfigurableMixin
from pyhanko.config.errors import ConfigurationError
__all__ = [
'LayoutError',
'BoxSpecificationError',
'BoxConstraints',
'AxisAlignment',
'Margins',
'InnerScaling',
'SimpleBoxLayoutRule',
'Positioning',
]
logger = logging.getLogger(__name__)
class LayoutError(ValueError):
"""Indicates an error in a layout computation."""
def __init__(self, msg: str, *args):
self.msg = msg
super().__init__(msg, *args)
class BoxSpecificationError(LayoutError):
"""Raised when a box constraint is over/underspecified."""
def __init__(self, msg: Optional[str] = None):
super().__init__(msg=msg or "box constraint is over/underspecified")
class BoxConstraints:
"""Represents a box of potentially variable width and height.
Among other uses, this can be leveraged to produce a variably sized
box with a fixed aspect ratio.
If width/height are not defined yet, they can be set by assigning to the
:attr:`width` and :attr:`height` attributes.
"""
_width: Optional[int]
_height: Optional[int]
_ar: Optional[Fraction]
_fully_specified: bool
def __init__(
self,
width: Union[int, float, None] = None,
height: Union[int, float, None] = None,
aspect_ratio: Optional[Fraction] = None,
):
int_width = int(width) if width is not None else None
int_height = int(height) if height is not None else None
self._width = int_width
self._height = int_height
fully_specified = False
self._ar = None
if int_width is None and int_height is None and aspect_ratio is None:
return
elif int_width is not None and int_height is not None:
if aspect_ratio is not None:
raise BoxSpecificationError # overspecified
self._ar = Fraction(int_width, int_height)
fully_specified = True
elif aspect_ratio is not None:
self._ar = aspect_ratio
if int_height is not None:
self._width = int(round(int_height * aspect_ratio))
elif int_width is not None:
self._height = int(round(int_width / aspect_ratio))
self._fully_specified = fully_specified
def _recalculate(self):
if self._width is not None and self._height is not None:
self._ar = Fraction(self._width, self._height)
self._fully_specified = True
elif self._ar is not None:
if self._height is not None:
self._width = int(self._height * self._ar)
self._fully_specified = True
elif self._width is not None:
self._height = int(self._width / self._ar)
self._fully_specified = True
@property
def width(self) -> int:
"""
:return:
The width of the box.
:raises BoxSpecificationError:
if the box's width could not be determined.
"""
if self._width is not None:
return self._width
else:
raise BoxSpecificationError
@width.setter
def width(self, width):
if self._width is None:
self._width = width
self._recalculate()
else:
raise BoxSpecificationError
@property
def width_defined(self) -> bool:
"""
:return:
``True`` if the box currently has a well-defined width,
``False`` otherwise.
"""
return self._width is not None
@property
def height(self) -> int:
"""
:return:
The height of the box.
:raises BoxSpecificationError:
if the box's height could not be determined.
"""
if self._height is not None:
return self._height
else:
raise BoxSpecificationError
@height.setter
def height(self, height):
if self._height is None:
self._height = height
self._recalculate()
else:
raise BoxSpecificationError
@property
def height_defined(self) -> bool:
"""
:return:
``True`` if the box currently has a well-defined height,
``False`` otherwise.
"""
return self._height is not None
@property
def aspect_ratio(self) -> Fraction:
"""
:return:
The aspect ratio of the box.
:raises BoxSpecificationError:
if the box's aspect ratio could not be determined.
"""
if self._ar is not None:
return self._ar
else:
raise BoxSpecificationError
@property
def aspect_ratio_defined(self) -> bool:
"""
:return:
``True`` if the box currently has a well-defined aspect ratio,
``False`` otherwise.
"""
return self._ar is not None
class InnerScaling(enum.Enum):
"""Class representing a scaling convention."""
NO_SCALING = enum.auto()
"""Never scale content."""
STRETCH_FILL = enum.auto()
"""Scale content to fill the entire container."""
STRETCH_TO_FIT = enum.auto()
"""
Scale content while preserving aspect ratio until either the maximal
width or maximal height is reached.
"""
SHRINK_TO_FIT = enum.auto()
"""
Scale content down to fit in the container, while preserving the original
aspect ratio.
"""
@classmethod
def from_config(cls, config_str: str) -> 'InnerScaling':
"""
Convert from a configuration string.
:param config_str:
A string: 'none', 'stretch-fill', 'stretch-to-fit', 'shrink-to-fit'
:return:
An :class:`.InnerScaling` value.
:raise ConfigurationError: on unexpected string inputs.
"""
try:
return {
'none': InnerScaling.NO_SCALING,
'stretch-fill': InnerScaling.STRETCH_FILL,
'stretch-to-fit': InnerScaling.STRETCH_TO_FIT,
'shrink-to-fit': InnerScaling.SHRINK_TO_FIT,
}[config_str.lower()]
except KeyError:
raise ConfigurationError(
f"'{config_str}' is not a valid inner scaling setting; valid "
f"values are 'none', 'stretch-fill', 'stretch-to-fit', "
f"'shrink-to-fit'."
)
class AxisAlignment(enum.Enum):
"""Class representing one-dimensional alignment along an axis."""
ALIGN_MIN = enum.auto()
"""
Align maximally towards the negative end of the axis.
"""
ALIGN_MID = enum.auto()
"""
Center content along the axis.
"""
ALIGN_MAX = enum.auto()
"""
Align maximally towards the positive end of the axis.
"""
@classmethod
def from_x_align(cls, align_str: str) -> 'AxisAlignment':
"""
Convert from a horizontal alignment config string.
:param align_str:
A string: 'left', 'mid' or 'right'.
:return:
An :class:`.AxisAlignment` value.
:raise ConfigurationError: on unexpected string inputs.
"""
try:
return {
'left': AxisAlignment.ALIGN_MIN,
'mid': AxisAlignment.ALIGN_MID,
'right': AxisAlignment.ALIGN_MAX,
}[align_str.lower()]
except KeyError:
raise ConfigurationError(
f"'{align_str}' is not a valid horizontal alignment; valid "
f"values are 'left', 'mid', 'right'."
)
@classmethod
def from_y_align(cls, align_str: str) -> 'AxisAlignment':
"""
Convert from a vertical alignment config string.
:param align_str:
A string: 'bottom', 'mid' or 'top'.
:return:
An :class:`.AxisAlignment` value.
:raise ConfigurationError: on unexpected string inputs.
"""
try:
return {
'bottom': AxisAlignment.ALIGN_MIN,
'mid': AxisAlignment.ALIGN_MID,
'top': AxisAlignment.ALIGN_MAX,
}[align_str.lower()]
except KeyError:
raise ConfigurationError(
f"'{align_str}' is not a valid vertical alignment; valid "
f"values are 'bottom', 'mid', 'top'."
)
@property
def flipped(self):
return _alignment_opposites[self]
def align(
self, container_len: int, inner_len: int, pre_margin, post_margin
) -> int:
effective_max_len = Margins.effective(
'length', container_len, pre_margin, post_margin
)
if self == AxisAlignment.ALIGN_MAX:
# we want to start as far up the axis as possible.
# Ignoring margins, that would be at container_len - inner_len
# This computation makes sure that there's room for post_margin
# in the back.
return container_len - inner_len - post_margin
elif self == AxisAlignment.ALIGN_MIN:
return pre_margin
elif inner_len > effective_max_len:
logger.warning(
f"Content box width/height {inner_len} is too wide for "
f"container size {container_len} with margins "
f"({pre_margin}, {post_margin}); post_margin will be ignored"
)
return pre_margin
elif self == AxisAlignment.ALIGN_MID:
# we'll center the inner content *within* the margins
inner_offset = (effective_max_len - inner_len) // 2
return pre_margin + inner_offset
raise TypeError
# Class variables in enums are weird, so let's put this here
_alignment_opposites = {
AxisAlignment.ALIGN_MID: AxisAlignment.ALIGN_MID,
AxisAlignment.ALIGN_MIN: AxisAlignment.ALIGN_MAX,
AxisAlignment.ALIGN_MAX: AxisAlignment.ALIGN_MIN,
}
@dataclass(frozen=True)
class Positioning(ConfigurableMixin):
"""
Class describing the position and scaling of an object in a container.
"""
x_pos: int
"""Horizontal coordinate"""
y_pos: int
"""Vertical coordinate"""
x_scale: float
"""Horizontal scaling"""
y_scale: float
"""Vertical scaling"""
def as_cm(self):
"""
Convenience method to convert this :class:`.Positioning` into a PDF
``cm`` operator.
:return:
A byte string representing the ``cm`` operator corresponding
to this :class:`.Positioning`.
"""
return b'%g 0 0 %g %g %g cm' % (
self.x_scale,
self.y_scale,
self.x_pos,
self.y_pos,
)
def _aln_width(
alignment: AxisAlignment,
container_box: BoxConstraints,
inner_nat_width: int,
pre_margin: int,
post_margin: int,
):
if container_box.width_defined:
return alignment.align(
container_box.width, inner_nat_width, pre_margin, post_margin
)
else:
container_box.width = inner_nat_width + pre_margin + post_margin
return pre_margin
def _aln_height(
alignment: AxisAlignment,
container_box: BoxConstraints,
inner_nat_height: int,
pre_margin: int,
post_margin: int,
):
if container_box.height_defined:
return alignment.align(
container_box.height, inner_nat_height, pre_margin, post_margin
)
else:
container_box.height = inner_nat_height + pre_margin + post_margin
return pre_margin
@dataclass(frozen=True)
class Margins(ConfigurableMixin):
"""Class describing a set of margins."""
left: int = 0
right: int = 0
top: int = 0
bottom: int = 0
@classmethod
def uniform(cls, num):
"""
Return a set of uniform margins.
:param num:
The uniform margin to apply to all four sides.
:return:
``Margins(num, num, num, num)``
"""
return Margins(num, num, num, num)
@staticmethod
def effective(dim_name, container_len, pre, post):
"""Internal helper method to compute effective margins."""
eff = container_len - pre - post
if eff < 0:
raise LayoutError(
f"Margins ({pre}, {post}) too wide for container "
f"{dim_name} {container_len}."
)
return eff
def effective_width(self, width):
"""
Compute width without margins.
:param width:
The container width.
:return:
The width after subtracting the left and right margins.
:raises LayoutError:
if the container width is too short to accommodate the margins.
"""
return Margins.effective('width', width, self.left, self.right)
def effective_height(self, height):
"""
Compute height without margins.
:param height:
The container height.
:return:
The height after subtracting the top and bottom margins.
:raises LayoutError:
if the container height is too short to accommodate the margins.
"""
return Margins.effective('height', height, self.bottom, self.top)
@classmethod
def from_config(cls, config_dict):
# convenience
if isinstance(config_dict, list):
config_dict = dict(
zip(("left", "right", "top", "bottom"), config_dict)
)
return super().from_config(config_dict)
@dataclass(frozen=True)
class SimpleBoxLayoutRule(ConfigurableMixin):
"""
Class describing alignment, scaling and margin rules for a box
positioned inside another box.
"""
x_align: AxisAlignment
"""
Horizontal alignment settings.
"""
y_align: AxisAlignment
"""
Vertical alignment settings.
"""
margins: Margins = Margins()
"""
Container (inner) margins. Defaults to all zeroes.
"""
inner_content_scaling: InnerScaling = InnerScaling.SHRINK_TO_FIT
"""
Inner content scaling rule.
"""
@classmethod
def process_entries(cls, config_dict):
# in config processing, we default to MID for everything
x_align = config_dict.get('x_align', AxisAlignment.ALIGN_MID)
if isinstance(x_align, str):
x_align = AxisAlignment.from_x_align(x_align)
config_dict['x_align'] = x_align
y_align = config_dict.get('y_align', AxisAlignment.ALIGN_MID)
if isinstance(y_align, str):
y_align = AxisAlignment.from_y_align(y_align)
config_dict['y_align'] = y_align
scaling = config_dict.get('inner_content_scaling', None)
if scaling is not None:
config_dict['inner_content_scaling'] = InnerScaling.from_config(
scaling
)
def substitute_margins(self, new_margins: Margins) -> 'SimpleBoxLayoutRule':
return SimpleBoxLayoutRule(
x_align=self.x_align,
y_align=self.y_align,
margins=new_margins,
inner_content_scaling=self.inner_content_scaling,
)
def fit(
self,
container_box: BoxConstraints,
inner_nat_width: int,
inner_nat_height: int,
) -> Positioning:
"""
Position and possibly scale a box within a container, according
to this layout rule.
:param container_box:
:class:`.BoxConstraints` describing the container.
:param inner_nat_width:
The inner box's natural width.
:param inner_nat_height:
The inner box's natural height.
:return:
A :class:`.Positioning` describing the scaling & position of the
lower left corner of the inner box.
"""
margins = self.margins
scaling = self.inner_content_scaling
x_scale = y_scale = 1
if (
scaling != InnerScaling.NO_SCALING
and container_box.width_defined
and container_box.height_defined
):
eff_width = margins.effective_width(container_box.width)
eff_height = margins.effective_height(container_box.height)
x_scale = (
(eff_width / inner_nat_width) if inner_nat_width != 0 else 1
)
y_scale = (
(eff_height / inner_nat_height) if inner_nat_height != 0 else 1
)
if scaling == InnerScaling.STRETCH_TO_FIT:
x_scale = y_scale = min(x_scale, y_scale)
elif scaling == InnerScaling.SHRINK_TO_FIT:
# same as stretch to fit, with the additional stipulation
# that it can't scale up, only down.
x_scale = y_scale = min(x_scale, y_scale, 1)
x_pos = _aln_width(
self.x_align,
container_box,
inner_nat_width * x_scale,
margins.left,
margins.right,
)
y_pos = _aln_height(
self.y_align,
container_box,
inner_nat_height * y_scale,
margins.bottom,
margins.top,
)
return Positioning(
x_pos=x_pos, y_pos=y_pos, x_scale=x_scale, y_scale=y_scale
)