| """Tiny self-contained layout primitives β our own engine, no external deps. |
| |
| The point of this module is **text-aware sizing**: every other part of the package can ask |
| "how wide is this string at this font size?" and "make this string fit in this width", so |
| boxes are sized to their content and text never overflows or runs together. Kept dependency- |
| free and deterministic (a fixed proportional-width table) so output stays reproducible. |
| """ |
|
|
| from __future__ import annotations |
|
|
|
|
| |
| |
| _NARROW = set("iIl.,:;'!|()[]{}ftj ") |
| _WIDE = set("mMW@%βββββ") |
| _MID = set("rtszc") |
|
|
|
|
| def char_em(ch: str) -> float: |
| if ch in _NARROW: |
| return 0.30 |
| if ch in _WIDE: |
| return 0.92 |
| if ch in _MID: |
| return 0.48 |
| if ch.isupper() or ch.isdigit(): |
| return 0.62 |
| return 0.54 |
|
|
|
|
| def text_width(s: str, px: float) -> float: |
| """Estimated rendered width (in px) of ``s`` at font size ``px``.""" |
| return sum(char_em(c) for c in s) * px |
|
|
|
|
| def fit_text(s: str, max_px: float, px: float) -> str: |
| """Return ``s`` unchanged if it fits in ``max_px``, else truncated with a trailing ellipsis.""" |
| if max_px <= 0 or text_width(s, px) <= max_px: |
| return s |
| ell = "β¦" |
| budget = max_px - text_width(ell, px) |
| out = [] |
| w = 0.0 |
| for c in s: |
| cw = char_em(c) * px |
| if w + cw > budget: |
| break |
| out.append(c) |
| w += cw |
| return ("".join(out).rstrip() + ell) if out else ell |
|
|
|
|
| def wrap_text(s: str, max_px: float, px: float, max_lines: int = 2) -> list[str]: |
| """Greedily wrap ``s`` to ``max_px`` over up to ``max_lines`` lines (last line ellipsized).""" |
| if text_width(s, px) <= max_px: |
| return [s] |
| words = s.split(" ") |
| lines: list[str] = [] |
| cur = "" |
| for w in words: |
| trial = (cur + " " + w).strip() |
| if text_width(trial, px) <= max_px or not cur: |
| cur = trial |
| else: |
| lines.append(cur) |
| cur = w |
| if len(lines) == max_lines - 1: |
| break |
| rest = " ".join(words[sum(len(line.split(" ")) for line in lines) :]) if lines else cur |
| lines.append(fit_text(rest or cur, max_px, px)) |
| return lines[:max_lines] |
|
|