from __future__ import annotations
from dataclasses import dataclass
from functools import partial
from itertools import tee
from typing import (
ClassVar,
Iterable,
Iterator,
Literal,
Protocol,
Sequence,
cast,
final,
)
from ..common import XY, Align, Pt, add_slots, advance, prepend, setattr_frozen
from ..resources import Resources
from ..style import Span, Style, StyledMixin, StyleFull, StyleLike
from ..typeset import firstfit, optimum
from ..typeset.layout import ShapedText
from ..typeset.parse import into_words
from ..typeset.state import Passage, State, max_lead, splitlines
from ..typeset.words import WordLike, indent_first
from .common import Block, ColumnFill
[docs]
@add_slots
@dataclass(frozen=True)
class LinebreakParams:
"""Parameters for tweaking the optimum-fit algorithm.
Parameters
----------
tolerance
The tolerance for the stretch of each line.
If no feasible solution is found, the tolerance is increased until
there is.
Increase the tolerance if you want to avoid hyphenation
at the cost of more stretching and longer runtime.
hyphen_penalty
The penalty for hyphenating a word. If increasing this value does
not result in fewer hyphens, try increasing the tolerance.
consecutive_hyphen_penalty
The penalty for placing hyphens on consecutive lines. If increasing
this value does not appear to work, try increasing the tolerance.
fitness_diff_penalty
The penalty for very tight and very loose lines following each other.
"""
tolerance: float = 1
hyphen_penalty: float = 1000
consecutive_hyphen_penalty: float = 1000
fitness_diff_penalty: float = 1000
DEFAULT: ClassVar["LinebreakParams"]
LinebreakParams.DEFAULT = LinebreakParams()
[docs]
@final
@add_slots
@dataclass(frozen=True, init=False)
class Paragraph(Block, StyledMixin):
"""A :class:`Block` that renders a paragraph of text.
Parameters
----------
content
The text to render. Can be a string, or a nested :class:`~pdfje.Span`.
style
The style to render the text with.
See :ref:`tutorial<style>` for more details.
align: Align | ``"left"`` | ``"center"`` | ``"right"`` | ``"justify"``
The horizontal alignment of the text.
indent
The amount of space to indent the first line of the paragraph.
avoid_orphans
Whether to avoid orphans (single lines before or after a page or
column break).
optimal
Whether to use the optimal paragraph layout algorithm.
If set to ``False`` or ``None``, a faster but less optimal algorithm
is used. To customize the algorithm parameters, pass an
:class:`~pdfje.layout.LinebreakParams` object.
Examples
--------
.. code-block:: python
from pdfje.layout import Paragraph
from pdfje.style import Style
Paragraph(
"This is a paragraph of text.",
style="#003311",
align="center",
)
Paragraph(
[
"It can also be ",
Span("styled", "#00ff00"),
" in multiple ",
Span("ways", Style(size=14, italic=True)),
".",
],
style=times_roman,
optimal=False,
)
"""
content: Sequence[str | Span]
style: Style
align: Align
indent: Pt
avoid_orphans: bool
optimal: LinebreakParams | None
def __init__(
self,
content: str | Span | Sequence[str | Span],
style: StyleLike = Style.EMPTY,
align: (
Align | Literal["left", "center", "right", "justify"]
) = Align.LEFT,
indent: Pt = 0,
avoid_orphans: bool = True,
optimal: LinebreakParams | bool | None = True,
):
if isinstance(content, (str, Span)):
content = [content]
setattr_frozen(self, "content", content)
setattr_frozen(self, "style", Style.parse(style))
setattr_frozen(self, "align", Align.parse(align))
setattr_frozen(self, "indent", indent)
setattr_frozen(self, "avoid_orphans", avoid_orphans)
if isinstance(optimal, bool):
optimal = LinebreakParams.DEFAULT if optimal else None
setattr_frozen(self, "optimal", optimal)
def into_columns(
self, res: Resources, style: StyleFull, cs: Iterator[ColumnFill]
) -> Iterator[ColumnFill]:
style |= self.style
state = style.as_state(res)
passages = list(self.flatten(res, style))
lead = max_lead(passages, state)
col = next(cs)
for para in splitlines(passages):
cs, _branch = tee(prepend(col, cs))
[*filled, col], state = _fill_paragraph(
iter(para),
_branch,
state,
self.indent,
lead,
self.align,
self.avoid_orphans,
shape=cast(
Shaper,
(
(partial(optimum.shape, params=self.optimal))
if self.optimal
else firstfit.shape
),
),
)
advance(cs, len(filled) + 1)
yield from filled
yield col
class Shaper(Protocol):
def __call__(
self,
ws: Iterator[WordLike],
columns: Iterator[XY],
allow_empty: bool,
lead: Pt,
avoid_orphans: bool,
align: Align,
) -> Iterator[ShapedText]: ...
def _fill_paragraph(
txt: Iterator[Passage],
cs: Iterator[ColumnFill],
state: State,
indent: Pt,
lead: Pt,
align: Align,
avoid_orphans: bool,
shape: Shaper,
) -> tuple[Iterable[ColumnFill], State]:
done: list[ColumnFill] = []
col = next(cs)
allow_empty = bool(col.blocks)
cmd, words = into_words(txt, state)
state = cmd.apply(state)
cs, _branch = tee(prepend(col, cs))
for chunk, col in zip( # pragma: no branch
shape(
indent_first(words, indent),
(XY(c.box.width, c.height_free) for c in _branch),
allow_empty=allow_empty,
lead=lead,
align=align,
avoid_orphans=avoid_orphans,
),
cs,
):
done.append(col.add(chunk))
state = chunk.end_state() or state
return done, state