Skip to content

Text Engine API

The text module handles font loading, text measurement, automatic line breaking, and vertical typesetting.

Module Overview

Submodule Responsibility
text.font Font manager (FontManager), FreeType/Pillow backends
text.shaper Text measurement, line breaking, alignment (measure_text, break_lines, align_lines)
text.embed Font subsetting and WOFF2 embedding

FontManager

Global singleton managing font discovery and loading.

from latticesvg.text.font import FontManager

fm = FontManager.instance()
path = fm.find_font("Noto Sans SC", weight="bold")
chain = fm.find_font_chain(["Times New Roman", "SimSun"], weight="normal")

Auto-generated API Docs

Font Management

font

Font loading, glyph measurement, and caching via FreeType (with Pillow fallback).

FontInfo dataclass
FontInfo(family: str, path: str, weight: str, style: str, format: str, face_index: int)

Metadata for an indexed font.

GlyphMetrics dataclass
GlyphMetrics(advance_x: float, bearing_x: float, bearing_y: float, width: float, height: float, advance_y: float = 0.0, vert_origin_x: float = 0.0, vert_origin_y: float = 0.0)

Metrics for a single glyph at a given size.

FontManager
FontManager()

Manages font discovery, loading, and glyph metric caching.

Usage::

fm = FontManager.instance()
path = fm.find_font(["Arial", "sans-serif"])
metrics = fm.glyph_metrics(path, 16, "A")
Source code in src/latticesvg/text/font.py
def __init__(self) -> None:
    self._backend = self._create_backend()
    self._cache: Dict[Tuple[str, int, str], GlyphMetrics] = {}
    self._font_index: Optional[Dict[str, str]] = None  # family_lower -> path
    self._extra_dirs: List[str] = []
reset classmethod
reset() -> None

Reset the singleton (useful for testing).

Source code in src/latticesvg/text/font.py
@classmethod
def reset(cls) -> None:
    """Reset the singleton (useful for testing)."""
    cls._singleton = None
add_font_directory
add_font_directory(path: str) -> None

Register an additional directory to search for fonts.

Source code in src/latticesvg/text/font.py
def add_font_directory(self, path: str) -> None:
    """Register an additional directory to search for fonts."""
    self._extra_dirs.append(path)
    self._font_index = None  # force re-scan
find_font
find_font(family_list: Optional[list] = None, weight: str = 'normal', style: str = 'normal') -> Optional[str]

Find a font file matching the requested family list.

Returns the first match found, or None.

Source code in src/latticesvg/text/font.py
def find_font(
    self,
    family_list: Optional[list] = None,
    weight: str = "normal",
    style: str = "normal",
) -> Optional[str]:
    """Find a font file matching the requested family list.

    Returns the first match found, or ``None``.
    """
    if family_list is None:
        family_list = ["sans-serif"]
    if isinstance(family_list, str):
        family_list = [family_list]

    idx = self._get_font_index()

    # Expand families  (e.g. "sans-serif" → multiple concrete names)
    expanded: List[str] = []
    for fam in family_list:
        if fam.lower() in _FAMILY_ALIASES:
            expanded.extend(_FAMILY_ALIASES[fam.lower()])
        else:
            expanded.append(fam)

    # Build candidate stems
    weight_suffix = ""
    if weight == "bold":
        weight_suffix = "bold"
    style_suffix = ""
    if style in ("italic", "oblique"):
        style_suffix = "italic"

    for name in expanded:
        candidates = self._name_candidates(name, weight_suffix, style_suffix)
        for c in candidates:
            if c in idx:
                return idx[c]

    # Last resort — return first font found in index
    if idx:
        return next(iter(idx.values()))

    return None
find_font_chain
find_font_chain(family_list: Optional[list] = None, weight: str = 'normal', style: str = 'normal') -> List[str]

Resolve each family to a font path, returning an ordered fallback chain.

Unlike :meth:find_font (which returns only the first match), this method resolves every distinct family in family_list so that characters missing in the primary font can be measured with a fallback font.

When a generic family (e.g. "sans-serif") is given, all concrete fonts reachable through the alias list are included in the chain, giving a wide Unicode coverage.

Source code in src/latticesvg/text/font.py
def find_font_chain(
    self,
    family_list: Optional[list] = None,
    weight: str = "normal",
    style: str = "normal",
) -> List[str]:
    """Resolve each family to a font path, returning an ordered fallback chain.

    Unlike :meth:`find_font` (which returns only the first match), this
    method resolves *every* distinct family in *family_list* so that
    characters missing in the primary font can be measured with a
    fallback font.

    When a generic family (e.g. ``"sans-serif"``) is given, **all**
    concrete fonts reachable through the alias list are included in
    the chain, giving a wide Unicode coverage.
    """
    if family_list is None:
        family_list = ["sans-serif"]
    if isinstance(family_list, str):
        family_list = [family_list]

    idx = self._get_font_index()
    weight_suffix = "bold" if weight == "bold" else ""
    style_suffix = "italic" if style in ("italic", "oblique") else ""

    chain: List[str] = []
    seen: set = set()

    for fam in family_list:
        # Expand generic families into their full alias list so
        # that *every* available concrete font is added — not
        # just the first hit.
        if fam.lower() in _FAMILY_ALIASES:
            names = _FAMILY_ALIASES[fam.lower()]
        else:
            names = [fam]

        for name in names:
            candidates = self._name_candidates(name, weight_suffix, style_suffix)
            for c in candidates:
                if c in idx:
                    path = idx[c]
                    if path not in seen:
                        chain.append(path)
                        seen.add(path)
                    break  # found this name, move to next

    if not chain:
        fallback = self.find_font(family_list, weight, style)
        if fallback:
            chain.append(fallback)
    return chain
glyph_metrics
glyph_metrics(font_path, size: int, char: str) -> GlyphMetrics

Return glyph metrics for char.

font_path may be a single path string or an ordered list of paths (a fallback chain). When a list is given the first font that contains a glyph for char is used.

Source code in src/latticesvg/text/font.py
def glyph_metrics(self, font_path, size: int, char: str) -> GlyphMetrics:
    """Return glyph metrics for *char*.

    *font_path* may be a single path string **or** an ordered list
    of paths (a fallback chain).  When a list is given the first
    font that contains a glyph for *char* is used.
    """
    if isinstance(font_path, list):
        return self._glyph_metrics_chain(font_path, size, char)
    key = (font_path, size, char)
    if key not in self._cache:
        self._cache[key] = self._backend.glyph_metrics(font_path, size, char)
    return self._cache[key]
font_family_name
font_family_name(font_path: str) -> Optional[str]

Get the CSS font-family name from a font file using FreeType.

Source code in src/latticesvg/text/font.py
def font_family_name(self, font_path: str) -> Optional[str]:
    """Get the CSS font-family name from a font file using FreeType."""
    key = ('_family_name', font_path)
    cached = self._cache.get(key)
    if cached is not None:
        return cached
    result: Optional[str] = None
    if isinstance(self._backend, _FreeTypeBackend):
        try:
            face = self._backend._freetype.Face(font_path)
            name = face.family_name
            if isinstance(name, bytes):
                name = name.decode('utf-8', errors='replace')
            result = name
        except Exception:
            pass
    if result is None:
        # Fallback: derive from filename stem
        result = Path(font_path).stem.replace('-', ' ').replace('_', ' ')
    self._cache[key] = result
    return result
get_font_path
get_font_path(family: str, weight: str = 'normal', style: str = 'normal') -> Optional[str]

Return the filesystem path for family, or None if not found.

Unlike :meth:find_font, this method does not fall back to an arbitrary default font when no match is found.

Source code in src/latticesvg/text/font.py
def get_font_path(
    self,
    family: str,
    weight: str = "normal",
    style: str = "normal",
) -> Optional[str]:
    """Return the filesystem path for *family*, or ``None`` if not found.

    Unlike :meth:`find_font`, this method does **not** fall back to an
    arbitrary default font when no match is found.
    """
    return self._resolve_single_family(family, weight, style)
list_fonts
list_fonts() -> List[FontInfo]

Return a :class:FontInfo for every indexed font file.

Source code in src/latticesvg/text/font.py
def list_fonts(self) -> List[FontInfo]:
    """Return a :class:`FontInfo` for every indexed font file."""
    idx = self._get_font_index()
    seen_paths: Dict[str, FontInfo] = {}
    for path in idx.values():
        if path in seen_paths:
            continue
        ext = Path(path).suffix.lower().lstrip(".")
        fmt = ext if ext in ("ttf", "otf", "ttc") else ext
        family_name = ""
        weight = "normal"
        style_name = "normal"
        face_index = 0
        if isinstance(self._backend, _FreeTypeBackend):
            try:
                face = self._backend._freetype.Face(path)
                raw_name = face.family_name
                if isinstance(raw_name, bytes):
                    raw_name = raw_name.decode("utf-8", errors="replace")
                family_name = raw_name or Path(path).stem
                raw_style = face.style_name
                if isinstance(raw_style, bytes):
                    raw_style = raw_style.decode("utf-8", errors="replace")
                sl = (raw_style or "").lower()
                weight = "bold" if "bold" in sl else "normal"
                style_name = "italic" if "italic" in sl or "oblique" in sl else "normal"
            except Exception:
                family_name = Path(path).stem
        else:
            family_name = Path(path).stem
        seen_paths[path] = FontInfo(
            family=family_name,
            path=path,
            weight=weight,
            style=style_name,
            format=fmt,
            face_index=face_index,
        )
    # For TTC files, enumerate additional faces
    ttc_extras: List[FontInfo] = []
    if isinstance(self._backend, _FreeTypeBackend):
        for path, info in list(seen_paths.items()):
            if info.format != "ttc":
                continue
            try:
                face = self._backend._freetype.Face(path)
                num = face.num_faces
                for fi in range(1, num):
                    face_i = self._backend._freetype.Face(path, fi)
                    raw_name = face_i.family_name
                    if isinstance(raw_name, bytes):
                        raw_name = raw_name.decode("utf-8", errors="replace")
                    raw_style = face_i.style_name
                    if isinstance(raw_style, bytes):
                        raw_style = raw_style.decode("utf-8", errors="replace")
                    sl = (raw_style or "").lower()
                    ttc_extras.append(FontInfo(
                        family=raw_name or family_name,
                        path=path,
                        weight="bold" if "bold" in sl else "normal",
                        style="italic" if "italic" in sl or "oblique" in sl else "normal",
                        format="ttc",
                        face_index=fi,
                    ))
            except Exception:
                pass
    result = list(seen_paths.values()) + ttc_extras
    result.sort(key=lambda f: (f.family.lower(), f.weight, f.style))
    return result
parse_font_families
parse_font_families(value) -> List[str]

Parse a CSS font-family value into a flat list of family names.

Accepts a comma-separated string, a list of strings, or None (which falls back to ["sans-serif"]).

Source code in src/latticesvg/text/font.py
def parse_font_families(value) -> List[str]:
    """Parse a CSS ``font-family`` value into a flat list of family names.

    Accepts a comma-separated string, a list of strings, or ``None``
    (which falls back to ``["sans-serif"]``).
    """
    if value is None:
        return ["sans-serif"]
    if isinstance(value, list):
        result: List[str] = []
        for item in value:
            if isinstance(item, str):
                result.extend(
                    x.strip().strip('"').strip("'")
                    for x in item.split(",")
                )
        return [x for x in result if x] or ["sans-serif"]
    if isinstance(value, str):
        return [
            x.strip().strip('"').strip("'")
            for x in value.split(",")
            if x.strip()
        ] or ["sans-serif"]
    return ["sans-serif"]

Text Shaping

shaper

Text shaping — line breaking, alignment, and overflow handling.

Line dataclass
Line(text: str, width: float, x_offset: float = 0.0, char_count: int = 0, justified: bool = False, word_spacing_justify: float = 0.0, hyphenated: bool = False)

A single line of shaped text.

SpanFragment dataclass
SpanFragment(text: str, width: float, span_index: int, font_path: str = '', font_size: int = 16, svg_fragment: Optional[object] = None)

A fragment of a TextSpan that lives on one rendered line.

RichLine dataclass
RichLine(fragments: List[SpanFragment] = list(), width: float = 0.0, x_offset: float = 0.0, justified: bool = False, word_spacing_justify: float = 0.0, _cjk_justify: bool = False, hyphenated: bool = False)

A single line of rich (multi-span) text.

VerticalRun dataclass
VerticalRun(text: str, upright: bool, advance: float = 0.0, combine: bool = False)

A contiguous run of characters with the same orientation.

upright runs have each character standing upright (CJK style). sideways (rotated) runs are Latin/digit text rotated 90° CW as a group. combine runs are tate-chū-yoko (纵中横): multiple characters compressed horizontally into a single em-square and rendered upright.

Column dataclass
Column(text: str, height: float, y_offset: float = 0.0, char_count: int = 0, runs: List[VerticalRun] = list())

A single column of vertically-set text (analogous to Line).

measure_text
measure_text(text: str, font_path: str, size: int, *, fm: Optional[FontManager] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0) -> float

Return the total advance width of text in pixels.

Source code in src/latticesvg/text/shaper.py
def measure_text(
    text: str,
    font_path: str,
    size: int,
    *,
    fm: Optional[FontManager] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
) -> float:
    """Return the total advance width of *text* in pixels."""
    if fm is None:
        fm = FontManager.instance()
    total = 0.0
    n = len(text)
    for i, ch in enumerate(text):
        gm = fm.glyph_metrics(font_path, size, ch)
        total += gm.advance_x
        # letter-spacing: extra space between characters (not after last)
        if letter_spacing and i < n - 1:
            total += letter_spacing
        # word-spacing: extra space on space characters
        if word_spacing and ch == ' ':
            total += word_spacing
    return total
measure_word
measure_word(word: str, font_path: str, size: int, *, fm: Optional[FontManager] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0) -> float

Measure a single word's advance width.

Source code in src/latticesvg/text/shaper.py
def measure_word(
    word: str,
    font_path: str,
    size: int,
    *,
    fm: Optional[FontManager] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
) -> float:
    """Measure a single word's advance width."""
    return measure_text(word, font_path, size, fm=fm,
                        letter_spacing=letter_spacing,
                        word_spacing=word_spacing)
break_lines
break_lines(text: str, available_width: float, font_path: str, size: int, white_space: str = 'normal', overflow_wrap: str = 'normal', *, fm: Optional[FontManager] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0, hyphens: str = 'none', lang: str = 'en') -> List[Line]

Break text into lines that fit within available_width.

PARAMETER DESCRIPTION
white_space

'normal' — collapse whitespace, wrap at word boundaries. 'nowrap' — collapse whitespace, no wrapping. 'pre' — preserve whitespace and newlines, no wrapping. 'pre-wrap' — preserve whitespace, wrap if needed.

TYPE: str DEFAULT: 'normal'

overflow_wrap

'normal' — break only at allowed break points. 'break-word' — allow breaking within words when a word overflows the available width. 'anywhere' — same as 'break-word' (simplified).

TYPE: str DEFAULT: 'normal'

Source code in src/latticesvg/text/shaper.py
def break_lines(
    text: str,
    available_width: float,
    font_path: str,
    size: int,
    white_space: str = "normal",
    overflow_wrap: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    hyphens: str = "none",
    lang: str = "en",
) -> List[Line]:
    """Break *text* into lines that fit within *available_width*.

    Parameters
    ----------
    white_space : str
        ``'normal'`` — collapse whitespace, wrap at word boundaries.
        ``'nowrap'`` — collapse whitespace, no wrapping.
        ``'pre'``    — preserve whitespace and newlines, no wrapping.
        ``'pre-wrap'`` — preserve whitespace, wrap if needed.
    overflow_wrap : str
        ``'normal'`` — break only at allowed break points.
        ``'break-word'`` — allow breaking within words when a word
        overflows the available width.
        ``'anywhere'`` — same as ``'break-word'`` (simplified).
    """
    if fm is None:
        fm = FontManager.instance()

    break_word = overflow_wrap in ("break-word", "anywhere")
    _ls, _ws = letter_spacing, word_spacing

    if white_space == "pre":
        return _break_pre(text, font_path, size, fm, wrap=False,
                          letter_spacing=_ls, word_spacing=_ws)
    if white_space == "pre-wrap":
        return _break_pre(text, font_path, size, fm, wrap=True, available_width=available_width,
                          letter_spacing=_ls, word_spacing=_ws)
    if white_space == "pre-line":
        return _break_pre_line(text, available_width, font_path, size, fm, break_word=break_word,
                               letter_spacing=_ls, word_spacing=_ws,
                               hyphens=hyphens, lang=lang)
    if white_space == "nowrap":
        # Collapse whitespace, single line
        collapsed = " ".join(text.split())
        if hyphens != "none":
            collapsed = _strip_shy(collapsed)
        w = measure_text(collapsed, font_path, size, fm=fm,
                         letter_spacing=_ls, word_spacing=_ws)
        return [Line(text=collapsed, width=w, char_count=len(collapsed))]

    # white_space == "normal" — default
    return _break_normal(text, available_width, font_path, size, fm, break_word=break_word,
                         letter_spacing=_ls, word_spacing=_ws,
                         hyphens=hyphens, lang=lang)
align_lines
align_lines(lines: List[Line], available_width: float, text_align: str = 'left') -> List[Line]

Set x_offset on each line based on text_align.

Does not mutate the input list; returns a new list.

For text-align: justify, computes per-word (or per-character for CJK) extra spacing. The last line is always left-aligned (CSS standard behaviour).

Source code in src/latticesvg/text/shaper.py
def align_lines(
    lines: List[Line],
    available_width: float,
    text_align: str = "left",
) -> List[Line]:
    """Set ``x_offset`` on each line based on *text_align*.

    Does **not** mutate the input list; returns a new list.

    For ``text-align: justify``, computes per-word (or per-character for
    CJK) extra spacing.  The last line is always left-aligned (CSS
    standard behaviour).
    """
    result: List[Line] = []
    num_lines = len(lines)
    for i, line in enumerate(lines):
        offset = 0.0
        justified = False
        word_gap = 0.0
        if text_align == "center":
            offset = (available_width - line.width) / 2.0
        elif text_align == "right":
            offset = available_width - line.width
        elif text_align == "justify" and i < num_lines - 1:
            extra = available_width - line.width
            if extra > 0:
                words = line.text.split()
                if len(words) > 1:
                    # Western text: distribute extra space across word gaps
                    word_gap = extra / (len(words) - 1)
                    justified = True
                elif line.char_count > 1 and ' ' not in line.text:
                    # CJK text without spaces: distribute across char gaps
                    word_gap = extra / (line.char_count - 1)
                    justified = True
        result.append(Line(
            text=line.text,
            width=line.width,
            x_offset=max(0.0, offset),
            char_count=line.char_count,
            justified=justified,
            word_spacing_justify=word_gap,
        ))
    return result
compute_text_block_size
compute_text_block_size(lines: List[Line], line_height: float, font_size: float) -> tuple

Return (width, height) of a text block.

line_height is the resolved line-height in absolute px. font_size is retained for API compatibility but unused when line_height is already absolute.

Source code in src/latticesvg/text/shaper.py
def compute_text_block_size(
    lines: List[Line],
    line_height: float,
    font_size: float,
) -> tuple:
    """Return ``(width, height)`` of a text block.

    *line_height* is the resolved line-height in absolute px.
    *font_size* is retained for API compatibility but unused when
    *line_height* is already absolute.
    """
    lh = line_height

    max_width = max((l.width for l in lines), default=0.0)
    total_height = lh * len(lines) if lines else 0.0
    return (max_width, total_height)
get_min_content_width
get_min_content_width(text: str, font_path: str, size: int, white_space: str = 'normal', *, fm: Optional[FontManager] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0, hyphens: str = 'none', lang: str = 'en') -> float

Return the minimum content width — the width of the longest word.

For CJK text each character is breakable so the min width is the widest single CJK character or the widest non-CJK word.

When hyphens is auto or manual, words can be split at hyphenation points so the min width is the widest syllable fragment (plus a trailing hyphen character).

Source code in src/latticesvg/text/shaper.py
def get_min_content_width(
    text: str,
    font_path: str,
    size: int,
    white_space: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    hyphens: str = "none",
    lang: str = "en",
) -> float:
    """Return the minimum content width — the width of the longest word.

    For CJK text each character is breakable so the min width is the
    widest single CJK character or the widest non-CJK word.

    When *hyphens* is ``auto`` or ``manual``, words can be split at
    hyphenation points so the min width is the widest syllable fragment
    (plus a trailing hyphen character).
    """
    if fm is None:
        fm = FontManager.instance()
    if white_space in ("pre", "nowrap"):
        return measure_text(text, font_path, size, fm=fm,
                            letter_spacing=letter_spacing, word_spacing=word_spacing)
    collapsed = " ".join(text.split())
    if not collapsed:
        return 0.0
    tokens = _tokenize_breakable(collapsed)
    max_w = 0.0
    _hyph_active = hyphens != "none"
    hyphen_w = measure_text("-", font_path, size, fm=fm) if _hyph_active else 0.0
    for token_text, is_space in tokens:
        if not is_space:
            if _hyph_active:
                # Split the word at hyphenation points and find the
                # widest syllable fragment (with hyphen appended to
                # all but the last fragment).
                clean = _strip_shy(token_text)
                if hyphens == "auto":
                    pts = _hyphen_points_auto(clean, lang)
                else:
                    pts = _hyphen_points_manual(token_text)
                if pts:
                    # Build syllable fragments
                    fragments: List[str] = []
                    if hyphens == "manual":
                        prev = 0
                        for p in pts:
                            frag = _strip_shy(token_text[prev:p])
                            fragments.append(frag)
                            prev = p + 1  # skip the SHY
                        tail = _strip_shy(token_text[prev:])
                        if tail:
                            fragments.append(tail)
                    else:
                        prev = 0
                        for p in pts:
                            fragments.append(clean[prev:p])
                            prev = p
                        tail = clean[prev:]
                        if tail:
                            fragments.append(tail)
                    for fi, frag in enumerate(fragments):
                        fw = measure_text(frag, font_path, size, fm=fm,
                                          letter_spacing=letter_spacing)
                        # All fragments except the last get a trailing hyphen
                        if fi < len(fragments) - 1:
                            fw += hyphen_w
                        max_w = max(max_w, fw)
                else:
                    w = measure_text(clean, font_path, size, fm=fm,
                                     letter_spacing=letter_spacing, word_spacing=word_spacing)
                    max_w = max(max_w, w)
            else:
                w = measure_text(token_text, font_path, size, fm=fm,
                                 letter_spacing=letter_spacing, word_spacing=word_spacing)
                max_w = max(max_w, w)
    return max_w
get_max_content_width
get_max_content_width(text: str, font_path: str, size: int, white_space: str = 'normal', *, fm: Optional[FontManager] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0) -> float

Return the max-content width — text on a single line.

Source code in src/latticesvg/text/shaper.py
def get_max_content_width(
    text: str,
    font_path: str,
    size: int,
    white_space: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
) -> float:
    """Return the max-content width — text on a single line."""
    if fm is None:
        fm = FontManager.instance()
    if white_space == "pre":
        # Longest physical line
        raw_lines = text.split("\n")
        return max((measure_text(l, font_path, size, fm=fm,
                                 letter_spacing=letter_spacing, word_spacing=word_spacing)
                    for l in raw_lines), default=0.0)
    collapsed = " ".join(text.split())
    return measure_text(collapsed, font_path, size, fm=fm,
                        letter_spacing=letter_spacing, word_spacing=word_spacing)
break_lines_rich
break_lines_rich(spans: 'List[TextSpan]', available_width: float, base_font_chain: list, base_size: int, white_space: str = 'normal', overflow_wrap: str = 'normal', *, fm: Optional[FontManager] = None, math_backend: Optional[object] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0, hyphens: str = 'none', lang: str = 'en') -> List[RichLine]

Break a list of TextSpan into RichLine objects.

This is the rich-text counterpart of :func:break_lines. Each span may use a different font/size; <br> spans force line breaks; <math> spans are measured via math_backend if provided.

Source code in src/latticesvg/text/shaper.py
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
def break_lines_rich(
    spans: "List[TextSpan]",
    available_width: float,
    base_font_chain: list,
    base_size: int,
    white_space: str = "normal",
    overflow_wrap: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    math_backend: Optional[object] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    hyphens: str = "none",
    lang: str = "en",
) -> List[RichLine]:
    """Break a list of ``TextSpan`` into ``RichLine`` objects.

    This is the rich-text counterpart of :func:`break_lines`.  Each span
    may use a different font/size; ``<br>`` spans force line breaks;
    ``<math>`` spans are measured via *math_backend* if provided.
    """
    from ..markup.parser import TextSpan  # type: ignore

    if fm is None:
        fm = FontManager.instance()

    break_word = overflow_wrap in ("break-word", "anywhere")
    collapse_ws = white_space in ("normal", "nowrap")
    wrap = white_space not in ("nowrap", "pre")
    _ls, _ws = letter_spacing, word_spacing

    # ---- pre-process spans: resolve fonts, handle <br> ----
    # Build a flat list of "segment" dicts ready for tokenization.
    segments: list = []  # list of dict with keys: text, span_idx, chain, size, is_math, svg_frag
    for idx, span in enumerate(spans):
        if span.is_line_break:
            segments.append({"br": True, "span_idx": idx})
            continue

        chain, sz = _resolve_span_font(span, base_font_chain, base_size, fm)
        fp = chain[0] if isinstance(chain, list) and chain else chain

        # Sub/super: if no explicit font-size override, measure at the
        # same shrunk size that the renderer will use (0.7×).
        if span.baseline_shift in ("super", "sub") and span.font_size is None:
            sz = max(1, int(base_size * 0.7))

        if span.is_math and math_backend is not None:
            # Render math and use its metrics as a single "token"
            svg_frag = math_backend.render(span.text, float(sz))
            segments.append({
                "br": False,
                "text": span.text,
                "span_idx": idx,
                "chain": chain,
                "fp": fp,
                "size": sz,
                "is_math": True,
                "svg_frag": svg_frag,
                "width": svg_frag.width,
            })
            continue

        text = span.text
        if collapse_ws:
            # Collapse internal whitespace runs to single space but
            # keep a single leading/trailing space so that inter-span
            # spacing is preserved (CSS inline whitespace behaviour).
            text = _re.sub(r'\s+', ' ', text) if text else ""

        segments.append({
            "br": False,
            "text": text,
            "span_idx": idx,
            "chain": chain,
            "fp": fp,
            "size": sz,
            "is_math": False,
            "svg_frag": None,
            "width": None,  # calculated per-token
        })

    # ---- Cross-span whitespace dedup ----
    # CSS collapses whitespace across element boundaries: if span A
    # ends with a space and span B starts with a space, only one
    # space should appear.  Also strip leading space on the very
    # first text segment (line-start behaviour).
    if collapse_ws:
        first_text_seen = False
        prev_ended_space = False
        for seg in segments:
            if seg.get("br"):
                prev_ended_space = False
                first_text_seen = False
                continue
            if seg.get("is_math"):
                prev_ended_space = False
                first_text_seen = True
                continue
            txt = seg["text"]
            if not txt:
                continue
            # Strip leading space if previous segment ended with one,
            # or if this is the very first text on the line.
            if (prev_ended_space or not first_text_seen) and txt.startswith(" "):
                txt = txt.lstrip(" ")
            seg["text"] = txt
            prev_ended_space = txt.endswith(" ") if txt else False
            first_text_seen = True

    # ---- tokenize and break lines ----
    lines: List[RichLine] = []
    cur_frags: List[SpanFragment] = []
    cur_width = 0.0
    _hyph_active = hyphens != "none"

    def _flush(hyphenated: bool = False) -> None:
        nonlocal cur_frags, cur_width
        # Strip soft-hyphen from all fragment texts
        if _hyph_active:
            cur_frags = [
                SpanFragment(
                    text=_strip_shy(f.text), width=_measure_span_text(_strip_shy(f.text), [f.font_path] if f.font_path else base_font_chain, f.font_size, fm, letter_spacing=_ls, word_spacing=_ws) if _SHY in f.text else f.width,
                    span_index=f.span_index, font_path=f.font_path, font_size=f.font_size, svg_fragment=f.svg_fragment,
                ) if f.svg_fragment is None and _SHY in f.text else f
                for f in cur_frags
            ]
        # Strip trailing whitespace fragment
        while cur_frags and cur_frags[-1].text.endswith(" "):
            last = cur_frags[-1]
            stripped = last.text.rstrip(" ")
            if stripped:
                new_w = _measure_span_text(stripped, [last.font_path] if last.font_path else base_font_chain, last.font_size, fm,
                                           letter_spacing=_ls, word_spacing=_ws)
                cur_frags[-1] = SpanFragment(
                    text=stripped, width=new_w,
                    span_index=last.span_index,
                    font_path=last.font_path, font_size=last.font_size,
                    svg_fragment=last.svg_fragment,
                )
            else:
                cur_frags.pop()
        total_w = sum(f.width for f in cur_frags)
        lines.append(RichLine(fragments=list(cur_frags), width=total_w,
                               hyphenated=hyphenated))
        cur_frags = []
        cur_width = 0.0

    for seg in segments:
        if seg.get("br"):
            _flush()
            continue

        if seg["is_math"]:
            # Math spans behave as a single indivisible token
            tw = seg["width"]
            if wrap and cur_frags and cur_width + tw > available_width:
                _flush()
            cur_frags.append(SpanFragment(
                text=seg["text"], width=tw,
                span_index=seg["span_idx"],
                font_path=seg["fp"] if isinstance(seg["fp"], str) else (seg["fp"][0] if seg["fp"] else ""),
                font_size=seg["size"],
                svg_fragment=seg["svg_frag"],
            ))
            cur_width += tw
            continue

        text = seg["text"]
        span_idx = seg["span_idx"]
        chain = seg["chain"]
        fp = seg["fp"]
        sz = seg["size"]

        if not text:
            continue

        # Tokenize this span's text
        if white_space in ("pre", "pre-wrap"):
            # Split on newlines first, preserve spaces
            sub_lines = text.split("\n")
            for li, sub in enumerate(sub_lines):
                if li > 0:
                    _flush()  # newline = force break
                tokens = _tokenize_breakable(sub) if sub else []
                for tok_text, is_space in tokens:
                    tok_w = _measure_span_text(tok_text, chain, sz, fm,
                                               letter_spacing=_ls, word_spacing=_ws)
                    if wrap and cur_frags and cur_width + tok_w > available_width and not is_space:
                        _flush()
                    cur_frags.append(SpanFragment(
                        text=tok_text, width=tok_w,
                        span_index=span_idx,
                        font_path=fp if isinstance(fp, str) else (fp[0] if fp else ""),
                        font_size=sz,
                    ))
                    cur_width += tok_w
        else:
            tokens = _tokenize_breakable(text)
            for tok_text, is_space in tokens:
                tok_measure = _strip_shy(tok_text) if (_hyph_active and _SHY in tok_text) else tok_text
                tok_w = _measure_span_text(tok_measure, chain, sz, fm,
                                           letter_spacing=_ls, word_spacing=_ws)

                fits = (not cur_frags) or (cur_width + tok_w <= available_width)

                if not fits and wrap:
                    if is_space:
                        _flush()
                        continue

                    if break_word and tok_w > available_width:
                        _flush()
                        # Character-by-character split
                        buf = ""
                        buf_w = 0.0
                        for ch in tok_text:
                            cw = fm.glyph_metrics(fp if isinstance(fp, str) else fp[0], sz, ch).advance_x
                            effective_cw = cw + _ls if buf else cw
                            if buf and buf_w + effective_cw > available_width:
                                cur_frags.append(SpanFragment(
                                    text=buf, width=buf_w,
                                    span_index=span_idx,
                                    font_path=fp if isinstance(fp, str) else (fp[0] if fp else ""),
                                    font_size=sz,
                                ))
                                cur_width = buf_w
                                _flush()
                                buf = ch
                                buf_w = cw
                            else:
                                if buf:
                                    buf_w += _ls  # letter-spacing between chars
                                buf += ch
                                buf_w += cw
                        if buf:
                            cur_frags.append(SpanFragment(
                                text=buf, width=buf_w,
                                span_index=span_idx,
                                font_path=fp if isinstance(fp, str) else (fp[0] if fp else ""),
                                font_size=sz,
                            ))
                            cur_width += buf_w
                    else:
                        # --- Hyphenation attempt (rich text) ---
                        _fp_str = fp if isinstance(fp, str) else (fp[0] if fp else "")
                        if _hyph_active and not is_space:
                            remaining = available_width - cur_width
                            hyp_w = _measure_span_text("-", chain, sz, fm, letter_spacing=_ls)

                            def _get_rich_hyph_pts(w):
                                if hyphens == "auto":
                                    return _hyphen_points_auto(_strip_shy(w), lang)
                                return _hyphen_points_manual(w)

                            def _meas_rich(w):
                                c = _strip_shy(w) if (_SHY in w) else w
                                return _measure_span_text(c, chain, sz, fm,
                                                          letter_spacing=_ls, word_spacing=_ws)

                            # First attempt: fit in remaining space
                            pts = _get_rich_hyph_pts(tok_text)
                            result = _try_hyphenate(
                                tok_text, remaining, _fp_str, sz, fm,
                                pts, _ls, hyp_w,
                                is_manual=(hyphens == "manual"),
                            )
                            if result is not None:
                                prefix, suffix = result
                                cur_frags.append(SpanFragment(
                                    text=prefix, width=_meas_rich(prefix),
                                    span_index=span_idx, font_path=_fp_str, font_size=sz,
                                ))
                                _flush(hyphenated=True)
                                word_rem = suffix
                            else:
                                # Remaining space too small — flush, retry on fresh line
                                _flush()
                                word_rem = tok_text

                            # Iteratively hyphenate until remainder fits
                            while True:
                                w_rem = _meas_rich(word_rem)
                                if w_rem <= available_width:
                                    break
                                pts2 = _get_rich_hyph_pts(word_rem)
                                r2 = _try_hyphenate(
                                    word_rem, available_width, _fp_str, sz, fm,
                                    pts2, _ls, hyp_w,
                                    is_manual=(hyphens == "manual"),
                                )
                                if r2 is not None:
                                    p2, s2 = r2
                                    cur_frags.append(SpanFragment(
                                        text=p2, width=_meas_rich(p2),
                                        span_index=span_idx, font_path=_fp_str, font_size=sz,
                                    ))
                                    _flush(hyphenated=True)
                                    word_rem = s2
                                else:
                                    break  # can't split further

                            cur_frags.append(SpanFragment(
                                text=word_rem, width=_meas_rich(word_rem),
                                span_index=span_idx, font_path=_fp_str, font_size=sz,
                            ))
                            cur_width = cur_frags[-1].width
                        else:
                            _flush()
                            cur_frags.append(SpanFragment(
                                text=tok_text, width=tok_w,
                                span_index=span_idx,
                                font_path=_fp_str,
                                font_size=sz,
                            ))
                            cur_width += tok_w
                else:
                    cur_frags.append(SpanFragment(
                        text=tok_text, width=tok_w,
                        span_index=span_idx,
                        font_path=fp if isinstance(fp, str) else (fp[0] if fp else ""),
                        font_size=sz,
                    ))
                    cur_width += tok_w

    # Flush remainder
    if cur_frags:
        _flush()

    if not lines:
        lines.append(RichLine())

    return lines
align_lines_rich
align_lines_rich(lines: List[RichLine], available_width: float, text_align: str = 'left') -> List[RichLine]

Set x_offset on each RichLine based on text_align.

For text-align: justify, counts space fragments as word gaps and distributes extra space evenly. The last line stays left-aligned.

Source code in src/latticesvg/text/shaper.py
def align_lines_rich(
    lines: List[RichLine],
    available_width: float,
    text_align: str = "left",
) -> List[RichLine]:
    """Set ``x_offset`` on each ``RichLine`` based on *text_align*.

    For ``text-align: justify``, counts space fragments as word gaps and
    distributes extra space evenly.  The last line stays left-aligned.
    """
    result: List[RichLine] = []
    num_lines = len(lines)
    for i, line in enumerate(lines):
        offset = 0.0
        justified = False
        word_gap = 0.0
        is_cjk_justify = False
        if text_align == "center":
            offset = (available_width - line.width) / 2.0
        elif text_align == "right":
            offset = available_width - line.width
        elif text_align == "justify" and i < num_lines - 1:
            extra = available_width - line.width
            if extra > 0:
                # Count space-only fragments as word boundaries
                space_count = 0
                has_non_space = False
                for frag in line.fragments:
                    if frag.svg_fragment is not None:
                        has_non_space = True
                        continue
                    if frag.text.strip():
                        has_non_space = True
                    elif frag.text == ' ':
                        space_count += 1
                if space_count > 0 and has_non_space:
                    word_gap = extra / space_count
                    justified = True
                elif space_count == 0 and has_non_space:
                    # CJK text: count total chars for per-char distribution
                    total_chars = sum(
                        len(f.text) for f in line.fragments
                        if f.svg_fragment is None and f.text.strip()
                    )
                    if total_chars > 1:
                        word_gap = extra / (total_chars - 1)
                        justified = True
                        is_cjk_justify = True
        result.append(RichLine(
            fragments=line.fragments,
            width=line.width,
            x_offset=max(0.0, offset),
            justified=justified,
            word_spacing_justify=word_gap,
            _cjk_justify=is_cjk_justify,
        ))
    return result
compute_rich_block_size
compute_rich_block_size(lines: List[RichLine], line_height: float, font_size: float) -> tuple

Return (width, height) for a rich text block.

Source code in src/latticesvg/text/shaper.py
def compute_rich_block_size(
    lines: List[RichLine],
    line_height: float,
    font_size: float,
) -> tuple:
    """Return ``(width, height)`` for a rich text block."""
    lh = line_height
    max_w = max((l.width for l in lines), default=0.0)
    total_h = lh * len(lines) if lines else 0.0
    return (max_w, total_h)
get_min_content_width_rich
get_min_content_width_rich(spans: 'List[TextSpan]', base_font_chain: list, base_size: int, white_space: str = 'normal', *, fm: Optional[FontManager] = None, math_backend: Optional[object] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0, hyphens: str = 'none', lang: str = 'en') -> float

Return the min-content width for a rich span list.

Source code in src/latticesvg/text/shaper.py
def get_min_content_width_rich(
    spans: "List[TextSpan]",
    base_font_chain: list,
    base_size: int,
    white_space: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    math_backend: Optional[object] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    hyphens: str = "none",
    lang: str = "en",
) -> float:
    """Return the min-content width for a rich span list."""
    from ..markup.parser import TextSpan  # type: ignore

    if fm is None:
        fm = FontManager.instance()
    if white_space in ("pre", "nowrap"):
        return get_max_content_width_rich(spans, base_font_chain, base_size, white_space, fm=fm, math_backend=math_backend,
                                          letter_spacing=letter_spacing, word_spacing=word_spacing)

    _hyph_active = hyphens != "none"
    max_w = 0.0
    for span in spans:
        if span.is_line_break:
            continue
        chain, sz = _resolve_span_font(span, base_font_chain, base_size, fm)
        fp = chain[0] if isinstance(chain, list) and chain else chain

        if span.baseline_shift in ("super", "sub") and span.font_size is None:
            sz = max(1, int(base_size * 0.7))

        if span.is_math and math_backend is not None:
            svg_frag = math_backend.render(span.text, float(sz))
            max_w = max(max_w, svg_frag.width)
            continue

        text = _re.sub(r'\s+', ' ', span.text).strip() if span.text.strip() else ""
        if not text:
            continue
        tokens = _tokenize_breakable(text)
        hyphen_w = _measure_span_text("-", chain, sz, fm) if _hyph_active else 0.0
        for tok_text, is_space in tokens:
            if not is_space:
                if _hyph_active:
                    clean = _strip_shy(tok_text)
                    if hyphens == "auto":
                        pts = _hyphen_points_auto(clean, lang)
                    else:
                        pts = _hyphen_points_manual(tok_text)
                    if pts:
                        fragments_r: List[str] = []
                        if hyphens == "manual":
                            prev_r = 0
                            for p in pts:
                                frag_r = _strip_shy(tok_text[prev_r:p])
                                fragments_r.append(frag_r)
                                prev_r = p + 1
                            tail_r = _strip_shy(tok_text[prev_r:])
                            if tail_r:
                                fragments_r.append(tail_r)
                        else:
                            prev_r = 0
                            for p in pts:
                                fragments_r.append(clean[prev_r:p])
                                prev_r = p
                            tail_r = clean[prev_r:]
                            if tail_r:
                                fragments_r.append(tail_r)
                        for fi, frag_r in enumerate(fragments_r):
                            fw = _measure_span_text(frag_r, chain, sz, fm,
                                                    letter_spacing=letter_spacing)
                            if fi < len(fragments_r) - 1:
                                fw += hyphen_w
                            max_w = max(max_w, fw)
                    else:
                        w = _measure_span_text(clean, chain, sz, fm,
                                               letter_spacing=letter_spacing, word_spacing=word_spacing)
                        max_w = max(max_w, w)
                else:
                    w = _measure_span_text(tok_text, chain, sz, fm,
                                           letter_spacing=letter_spacing, word_spacing=word_spacing)
                    max_w = max(max_w, w)
    return max_w
get_max_content_width_rich
get_max_content_width_rich(spans: 'List[TextSpan]', base_font_chain: list, base_size: int, white_space: str = 'normal', *, fm: Optional[FontManager] = None, math_backend: Optional[object] = None, letter_spacing: float = 0.0, word_spacing: float = 0.0) -> float

Return the max-content width for a rich span list (single line).

Source code in src/latticesvg/text/shaper.py
def get_max_content_width_rich(
    spans: "List[TextSpan]",
    base_font_chain: list,
    base_size: int,
    white_space: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    math_backend: Optional[object] = None,
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
) -> float:
    """Return the max-content width for a rich span list (single line)."""
    from ..markup.parser import TextSpan  # type: ignore

    if fm is None:
        fm = FontManager.instance()

    total = 0.0
    for span in spans:
        if span.is_line_break:
            continue
        chain, sz = _resolve_span_font(span, base_font_chain, base_size, fm)

        if span.baseline_shift in ("super", "sub") and span.font_size is None:
            sz = max(1, int(base_size * 0.7))

        if span.is_math and math_backend is not None:
            svg_frag = math_backend.render(span.text, float(sz))
            total += svg_frag.width
            continue

        text = span.text
        if white_space not in ("pre", "pre-wrap"):
            text = _re.sub(r'\s+', ' ', text) if text else ""
        w = _measure_span_text(text, chain, sz, fm,
                               letter_spacing=letter_spacing, word_spacing=word_spacing)
        total += w
    return total
measure_text_vertical
measure_text_vertical(text: str, font_path, size: int, *, fm: Optional[FontManager] = None, orientation: str = 'mixed', letter_spacing: float = 0.0, word_spacing: float = 0.0, text_combine_upright: str = 'none') -> float

Return the total advance height of text in vertical writing mode.

Upright characters contribute their vertical advance (advance_y, falling back to advance_x for fonts lacking vertical metrics). Sideways (rotated) runs contribute the horizontal width of the run as vertical advance (since the run is rotated 90° CW). Combine (纵中横) runs occupy one em-square of vertical advance.

Source code in src/latticesvg/text/shaper.py
def measure_text_vertical(
    text: str,
    font_path,
    size: int,
    *,
    fm: Optional[FontManager] = None,
    orientation: str = "mixed",
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    text_combine_upright: str = "none",
) -> float:
    """Return the total advance height of *text* in vertical writing mode.

    Upright characters contribute their vertical advance (``advance_y``,
    falling back to ``advance_x`` for fonts lacking vertical metrics).
    Sideways (rotated) runs contribute the horizontal width of the run
    as vertical advance (since the run is rotated 90° CW).
    Combine (纵中横) runs occupy one em-square of vertical advance.
    """
    if fm is None:
        fm = FontManager.instance()
    if not text:
        return 0.0
    raw_runs = _segment_vertical_runs(text, orientation)
    tcu_runs = _apply_text_combine_upright(raw_runs, text_combine_upright)
    total = 0.0
    for run_text, upright, combine in tcu_runs:
        if combine:
            # Combine run: occupies one em-square vertically
            gm = fm.glyph_metrics(font_path, size, run_text[0])
            adv_y = gm.advance_y if gm.advance_y > 0 else gm.advance_x
            total += adv_y
        elif upright:
            for i, ch in enumerate(run_text):
                gm = fm.glyph_metrics(font_path, size, ch)
                adv_y = gm.advance_y if gm.advance_y > 0 else gm.advance_x
                total += adv_y
                if letter_spacing and i < len(run_text) - 1:
                    total += letter_spacing
                if word_spacing and ch == ' ':
                    total += word_spacing
        else:
            # Sideways run: horizontal width becomes vertical extent
            run_w = measure_text(run_text, font_path, size, fm=fm,
                                 letter_spacing=letter_spacing,
                                 word_spacing=word_spacing)
            total += run_w
        # letter-spacing between runs
        if letter_spacing:
            total += letter_spacing
    # Remove trailing letter-spacing
    if letter_spacing and tcu_runs:
        total -= letter_spacing
    return total
break_columns
break_columns(text: str, available_height: float, font_path, size: int, white_space: str = 'normal', overflow_wrap: str = 'normal', *, fm: Optional[FontManager] = None, orientation: str = 'mixed', letter_spacing: float = 0.0, word_spacing: float = 0.0, text_combine_upright: str = 'none') -> List[Column]

Break text into columns that fit within available_height.

This is the vertical counterpart of :func:break_lines. Each Column represents one vertical column of text.

Characters are distributed top-to-bottom; when a column's height exceeds available_height, a new column starts.

Source code in src/latticesvg/text/shaper.py
def break_columns(
    text: str,
    available_height: float,
    font_path,
    size: int,
    white_space: str = "normal",
    overflow_wrap: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    orientation: str = "mixed",
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    text_combine_upright: str = "none",
) -> List[Column]:
    """Break *text* into columns that fit within *available_height*.

    This is the vertical counterpart of :func:`break_lines`.  Each
    ``Column`` represents one vertical column of text.

    Characters are distributed top-to-bottom; when a column's height
    exceeds *available_height*, a new column starts.
    """
    if fm is None:
        fm = FontManager.instance()

    # Collapse whitespace (simplified — uses same model as horizontal)
    if white_space in ("normal", "nowrap"):
        collapsed = " ".join(text.split())
        if not collapsed:
            return [Column(text="", height=0.0, char_count=0)]
        text = collapsed

    if white_space == "nowrap":
        h = measure_text_vertical(text, font_path, size, fm=fm,
                                  orientation=orientation,
                                  letter_spacing=letter_spacing,
                                  word_spacing=word_spacing,
                                  text_combine_upright=text_combine_upright)
        runs = _build_vertical_runs(text, font_path, size, fm, orientation,
                                    letter_spacing, word_spacing,
                                    text_combine_upright=text_combine_upright)
        return [Column(text=text, height=h, char_count=len(text), runs=runs)]

    # Handle pre modes with explicit newlines
    if white_space in ("pre", "pre-wrap", "pre-line"):
        segments = text.split("\n")
        columns: List[Column] = []
        for seg in segments:
            if white_space == "pre-line":
                seg = " ".join(seg.split())
            if white_space == "pre" or not seg.strip():
                h = measure_text_vertical(seg, font_path, size, fm=fm,
                                          orientation=orientation,
                                          letter_spacing=letter_spacing,
                                          word_spacing=word_spacing,
                                          text_combine_upright=text_combine_upright)
                runs = _build_vertical_runs(seg, font_path, size, fm,
                                            orientation, letter_spacing,
                                            word_spacing,
                                            text_combine_upright=text_combine_upright)
                columns.append(Column(text=seg, height=h,
                                     char_count=len(seg), runs=runs))
            else:
                sub = _break_column_normal(seg, available_height, font_path,
                                           size, fm, orientation,
                                           letter_spacing, word_spacing,
                                           text_combine_upright=text_combine_upright)
                columns.extend(sub)
        return columns if columns else [Column(text="", height=0.0,
                                               char_count=0)]

    # Normal wrapping
    return _break_column_normal(text, available_height, font_path, size, fm,
                                orientation, letter_spacing, word_spacing,
                                text_combine_upright=text_combine_upright)
align_columns
align_columns(columns: List[Column], available_height: float, text_align: str = 'left') -> List[Column]

Set y_offset on each column based on text_align.

In vertical mode, text-align controls the block-direction (vertical) alignment within each column: - left / start → top-aligned (y_offset = 0) - center → vertically centred - right / end → bottom-aligned

Source code in src/latticesvg/text/shaper.py
def align_columns(
    columns: List[Column],
    available_height: float,
    text_align: str = "left",
) -> List[Column]:
    """Set ``y_offset`` on each column based on *text_align*.

    In vertical mode, ``text-align`` controls the *block-direction*
    (vertical) alignment within each column:
      - ``left`` / ``start`` → top-aligned (y_offset = 0)
      - ``center`` → vertically centred
      - ``right`` / ``end`` → bottom-aligned
    """
    result: List[Column] = []
    for col in columns:
        offset = 0.0
        if text_align == "center":
            offset = (available_height - col.height) / 2.0
        elif text_align in ("right", "end"):
            offset = available_height - col.height
        result.append(Column(
            text=col.text,
            height=col.height,
            y_offset=max(0.0, offset),
            char_count=col.char_count,
            runs=col.runs,
        ))
    return result
compute_vertical_block_size
compute_vertical_block_size(columns: List[Column], line_height: float, font_size: float) -> Tuple[float, float]

Return (width, height) for a vertical text block.

width = number of columns × line_height (inter-column spacing). height = tallest column.

Source code in src/latticesvg/text/shaper.py
def compute_vertical_block_size(
    columns: List[Column],
    line_height: float,
    font_size: float,
) -> Tuple[float, float]:
    """Return ``(width, height)`` for a vertical text block.

    *width* = number of columns × line_height (inter-column spacing).
    *height* = tallest column.
    """
    if not columns:
        return (0.0, 0.0)
    lh = line_height
    total_width = lh * len(columns)
    max_height = max((c.height for c in columns), default=0.0)
    return (total_width, max_height)
get_min_content_height
get_min_content_height(text: str, font_path, size: int, white_space: str = 'normal', *, fm: Optional[FontManager] = None, orientation: str = 'mixed', letter_spacing: float = 0.0, word_spacing: float = 0.0, text_combine_upright: str = 'none') -> float

Return the minimum content height for vertical text.

This is the height of the tallest single breakable unit — analogous to get_min_content_width for horizontal text.

Source code in src/latticesvg/text/shaper.py
def get_min_content_height(
    text: str,
    font_path,
    size: int,
    white_space: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    orientation: str = "mixed",
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    text_combine_upright: str = "none",
) -> float:
    """Return the minimum content height for vertical text.

    This is the height of the tallest single breakable unit — analogous
    to ``get_min_content_width`` for horizontal text.
    """
    if fm is None:
        fm = FontManager.instance()
    if white_space in ("pre", "nowrap"):
        return measure_text_vertical(text, font_path, size, fm=fm,
                                     orientation=orientation,
                                     letter_spacing=letter_spacing,
                                     word_spacing=word_spacing,
                                     text_combine_upright=text_combine_upright)
    collapsed = " ".join(text.split())
    if not collapsed:
        return 0.0
    tokens = _tokenize_breakable(collapsed)
    max_h = 0.0
    for token_text, is_space in tokens:
        if not is_space:
            h = measure_text_vertical(token_text, font_path, size, fm=fm,
                                      orientation=orientation,
                                      letter_spacing=letter_spacing,
                                      word_spacing=word_spacing,
                                      text_combine_upright=text_combine_upright)
            max_h = max(max_h, h)
    return max_h
get_max_content_height
get_max_content_height(text: str, font_path, size: int, white_space: str = 'normal', *, fm: Optional[FontManager] = None, orientation: str = 'mixed', letter_spacing: float = 0.0, word_spacing: float = 0.0, text_combine_upright: str = 'none') -> float

Return the max-content height for vertical text — all text in one column.

Source code in src/latticesvg/text/shaper.py
def get_max_content_height(
    text: str,
    font_path,
    size: int,
    white_space: str = "normal",
    *,
    fm: Optional[FontManager] = None,
    orientation: str = "mixed",
    letter_spacing: float = 0.0,
    word_spacing: float = 0.0,
    text_combine_upright: str = "none",
) -> float:
    """Return the max-content height for vertical text — all text in one column."""
    if fm is None:
        fm = FontManager.instance()
    if white_space == "pre":
        lines = text.split("\n")
        return max(
            (measure_text_vertical(l, font_path, size, fm=fm,
                                   orientation=orientation,
                                   letter_spacing=letter_spacing,
                                   word_spacing=word_spacing,
                                   text_combine_upright=text_combine_upright)
             for l in lines),
            default=0.0,
        )
    collapsed = " ".join(text.split())
    return measure_text_vertical(collapsed, font_path, size, fm=fm,
                                 orientation=orientation,
                                 letter_spacing=letter_spacing,
                                 word_spacing=word_spacing,
                                 text_combine_upright=text_combine_upright)

Font Embedding

embed

Font subsetting and embedding for self-contained SVG output.

Collects all characters used per font path from the laid-out node tree, creates WOFF2 subsets via fontTools, and generates @font-face CSS rules that are injected into the drawsvg.Drawing.

Requires the optional fonttools package (with the brotli extension for WOFF2 compression). When these are unavailable, the module raises ImportError at call time — it is never imported unconditionally.

embed_fonts
embed_fonts(drawing: 'dw.Drawing', root: 'Node') -> None

Collect used glyphs, subset fonts, and embed @font-face rules.

Modifies drawing in place by appending CSS via drawing.append_css().

Raises ImportError if fonttools is not installed.

Source code in src/latticesvg/text/embed.py
def embed_fonts(drawing: "dw.Drawing", root: "Node") -> None:
    """Collect used glyphs, subset fonts, and embed ``@font-face`` rules.

    Modifies *drawing* in place by appending CSS via
    ``drawing.append_css()``.

    Raises ``ImportError`` if *fonttools* is not installed.
    """
    # Eagerly check for fonttools
    try:
        from fontTools.ttLib import TTFont  # noqa: F401
        from fontTools.subset import Subsetter  # noqa: F401
    except ImportError:
        raise ImportError(
            "fonttools is required for font embedding.  "
            "Install it with:  pip install fonttools"
        ) from None

    usage = _collect_font_usage(root)
    if not usage:
        return

    css = _generate_font_face_css(usage)
    if css:
        drawing.append_css(css)