File size: 3,237 Bytes
df84e45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""Text layout, written to match what a browser does with the editor's CSS.

The editor draws a text element as one box with `white-space: pre`,
`line-height: <font_size * line_height>px`, `letter-spacing` and `text-align`.
This module reproduces that box model for skia, so the server render and the
browser render put glyphs in the same place:

- Lines are the explicit `\\n` breaks. Nothing wraps.
- Every line box is `font_size * line_height` tall.
- The baseline sits where CSS puts it: half of the leading above the font's
  ascent, i.e. `top + (L - (ascent + descent)) / 2 + ascent`.
- `letter-spacing` is added after every character, trailing one included, which
  is Chromium's behaviour and why right-aligned spaced text keeps a gap.
"""

from __future__ import annotations

from dataclasses import dataclass

from .doc import Element
from .fonts import FontRegistry


@dataclass
class Piece:
    text: str
    color: str
    bold: bool
    italic: bool
    width: float


@dataclass
class Line:
    pieces: list[Piece]
    width: float
    x: float  # offset inside the box, from alignment
    top: float  # offset of the line box inside the element
    baseline: float  # offset of the baseline inside the element


@dataclass
class TextLayout:
    lines: list[Line]
    width: float  # widest line
    height: float  # line count * line box height
    line_box: float


def _split_lines(el: Element) -> list[list[tuple[str, object]]]:
    """Runs cut at newline boundaries, one list of (text, run) per line."""
    lines: list[list[tuple[str, object]]] = [[]]
    for run in el.styled_runs():
        text = run.text.upper() if el.uppercase else run.text
        parts = text.split("\n")
        for i, part in enumerate(parts):
            if i > 0:
                lines.append([])
            if part:
                lines[-1].append((part, run))
    return lines


def layout(el: Element, fonts: FontRegistry) -> TextLayout:
    size = el.font_size
    line_box = size * el.line_height
    ascent, descent = fonts.metrics(el.font_family or "", size, el.bold, el.italic)
    half_leading = (line_box - (ascent + descent)) / 2
    lines = []
    for i, raw in enumerate(_split_lines(el)):
        pieces = []
        for text, run in raw:
            w = fonts.advance(
                text, el.font_family or "", size, run.bold, run.italic, el.letter_spacing
            )
            pieces.append(Piece(text, run.color, bool(run.bold), bool(run.italic), w))
        width = sum(p.width for p in pieces)
        if el.align == "center":
            x = (el.width - width) / 2
        elif el.align == "right":
            x = el.width - width
        else:
            x = 0.0
        top = i * line_box
        lines.append(Line(pieces, width, x, top, top + half_leading + ascent))
    widest = max((ln.width for ln in lines), default=0.0)
    return TextLayout(lines, widest, max(1.0, len(lines) * line_box), line_box)


def fit_box(el: Element, fonts: FontRegistry) -> tuple[float, float]:
    """(width, height) the element should have after a text or style change."""
    lay = layout(el, fonts)
    width = max(1.0, round(lay.width + 1.0, 2)) if el.auto_width else el.width
    return width, round(lay.height, 2)