586 lines
17 KiB
Python

"""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
)