Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_to_pptx/text_outline.py
T

1122 lines
33 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Text Outline Materialization
Resolve one supported SVG ``<text>`` operand into closed glyph-outline path
commands for the Shape Boolean geometry pipeline.
Usage:
Import text_element_to_path_commands from svg_to_pptx.text_outline.
Dependencies:
uharfbuzz and local PPT Master modules
"""
from __future__ import annotations
import bisect
import math
import os
import sys
import unicodedata
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from .drawingml.paths import PathCommand, normalize_path_commands
from .drawingml.text_properties import (
normalize_project_text_segments,
parse_project_font_style,
parse_project_font_weight,
parse_project_letter_spacing,
parse_project_text_anchor,
parse_project_text_decoration,
resolve_project_xml_space,
)
from .drawingml.utils import (
font_px_to_hpt,
parse_inline_style,
parse_project_geometry_length,
parse_svg_length,
split_project_text_clusters,
)
_FONT_EXTENSIONS = frozenset({".otc", ".otf", ".ttc", ".ttf"})
_FAMILY_NAME_IDS = (16, 1, 21)
_GENERIC_FAMILIES = {
"sans-serif": (
"Segoe UI",
"Arial",
"Helvetica",
"Noto Sans",
"Liberation Sans",
"DejaVu Sans",
"Nimbus Sans",
"Noto Sans CJK SC",
"Source Han Sans SC",
"Droid Sans Fallback",
),
"system-ui": (
"Segoe UI",
"SF Pro",
"Helvetica Neue",
"Noto Sans",
"Liberation Sans",
"DejaVu Sans",
"Noto Sans CJK SC",
"Droid Sans Fallback",
),
"serif": (
"Times New Roman",
"Noto Serif",
"Liberation Serif",
"DejaVu Serif",
"Nimbus Roman",
"Noto Serif CJK SC",
"Source Han Serif SC",
),
"monospace": (
"Consolas",
"Courier New",
"Noto Sans Mono",
"Liberation Mono",
"DejaVu Sans Mono",
"Nimbus Mono PS",
"Noto Sans Mono CJK SC",
),
}
_GENERIC_FAMILIES["ui-sans-serif"] = _GENERIC_FAMILIES["system-ui"]
_GENERIC_FAMILIES["ui-serif"] = _GENERIC_FAMILIES["serif"]
_GENERIC_FAMILIES["ui-monospace"] = _GENERIC_FAMILIES["monospace"]
_UNSUPPORTED_TEXT_PROPERTIES = frozenset({
"alignment-baseline",
"baseline-shift",
"direction",
"dominant-baseline",
"font-feature-settings",
"font-kerning",
"font-size-adjust",
"font-stretch",
"font-synthesis",
"font-variant",
"font-variation-settings",
"font",
"hyphens",
"kerning",
"line-height",
"overflow-wrap",
"text-align",
"text-align-last",
"text-indent",
"text-orientation",
"text-rendering",
"text-transform",
"unicode-bidi",
"vertical-align",
"white-space",
"word-spacing",
"word-break",
"writing-mode",
})
_UNSUPPORTED_TEXT_ATTRIBUTES = frozenset({
"dx",
"dy",
"lengthAdjust",
"rotate",
"textLength",
})
_UNSUPPORTED_BIDI_CLASSES = frozenset({
"AL",
"FSI",
"LRE",
"LRI",
"LRO",
"PDF",
"PDI",
"R",
"RLE",
"RLI",
"RLO",
})
@dataclass(frozen=True)
class _FontFace:
path: Path
index: int
families: tuple[str, ...]
weight: int
italic: bool
weight_range: tuple[float, float] | None
italic_range: tuple[float, float] | None
coverage: frozenset[int]
order: int
@dataclass(frozen=True)
class _TextStyle:
families: tuple[str, ...]
font_size: float
font_weight: int
italic: bool
anchor: str
letter_spacing: float
@dataclass(frozen=True)
class _FontRun:
face: _FontFace
clusters: tuple[str, ...]
global_cluster_start: int
@property
def text(self) -> str:
return "".join(self.clusters)
@dataclass
class _ShapedRun:
run: _FontRun
font: Any
infos: Sequence[Any]
positions: Sequence[Any]
scale: float
advance_x: float
@dataclass
class _DrawState:
origin_x: float
origin_y: float
scale: float
commands: list[PathCommand]
segment_count: int = 0
def point(self, x: float, y: float) -> tuple[float, float]:
return (
self.origin_x + x * self.scale,
self.origin_y - y * self.scale,
)
def text_element_to_path_commands(
element: ET.Element,
parents: Mapping[ET.Element, ET.Element],
*,
font_dirs: Sequence[str | Path] = (),
) -> list[PathCommand]:
"""Return closed glyph outlines for one supported SVG ``<text>`` element."""
chain = _element_chain(element, parents)
_validate_text_structure(element, chain)
text = _normalized_direct_text(element, chain)
if not text:
raise ValueError(f"{_element_label(element)} has no rendered text")
if any(
unicodedata.bidirectional(character)
in _UNSUPPORTED_BIDI_CLASSES
for character in text
):
raise ValueError(
f"{_element_label(element)} uses right-to-left or bidirectional "
"text; materialize its glyph outlines before Shape Boolean"
)
style = _resolve_text_style(chain)
x = _text_position(element, "x")
y = _text_position(element, "y")
explicit_roots = _normalize_explicit_font_dirs(font_dirs)
catalog = _font_catalog(explicit_roots)
candidates = _font_candidates(
catalog,
style.families,
style.font_weight,
style.italic,
)
if not candidates:
family_list = ", ".join(style.families)
raise ValueError(
f"{_element_label(element)} cannot resolve an installed font from "
f"{family_list!r}; add a usable fallback or pass --font-dir"
)
clusters = split_project_text_clusters(text)
runs = _font_runs(
element,
clusters,
candidates,
style.families,
)
shaped_runs = _shape_runs(
runs,
style.font_size,
style.font_weight,
style.italic,
style.letter_spacing,
)
natural_width = sum(run.advance_x for run in shaped_runs)
tracked_width = (
natural_width
+ max(0, len(clusters) - 1) * style.letter_spacing
)
if not math.isfinite(tracked_width) or tracked_width <= 0:
raise ValueError(
f"{_element_label(element)} resolves to a non-positive text width"
)
anchor_shift = {
"start": 0.0,
"middle": -tracked_width / 2.0,
"end": -tracked_width,
}[style.anchor]
hb = _load_harfbuzz()
draw_funcs = _draw_functions(hb)
commands: list[PathCommand] = []
natural_run_x = 0.0
for shaped in shaped_runs:
run_offsets = _cluster_offsets(shaped.run.clusters)
glyph_cursor_x = 0.0
glyph_cursor_y = 0.0
for info, position in zip(shaped.infos, shaped.positions):
if info.codepoint == 0:
raise ValueError(
f"{_element_label(element)} shaped a missing glyph in "
f"{shaped.run.face.path.name}"
)
cluster_index = max(
0,
bisect.bisect_right(run_offsets, info.cluster) - 1,
)
cluster_index = min(
cluster_index,
len(shaped.run.clusters) - 1,
)
global_cluster = (
shaped.run.global_cluster_start + cluster_index
)
spacing_shift = global_cluster * style.letter_spacing
state = _DrawState(
origin_x=(
x
+ anchor_shift
+ natural_run_x
+ glyph_cursor_x
+ position.x_offset * shaped.scale
+ spacing_shift
),
origin_y=(
y
- glyph_cursor_y
- position.y_offset * shaped.scale
),
scale=shaped.scale,
commands=commands,
)
shaped.font.draw_glyph(info.codepoint, draw_funcs, state)
if state.segment_count == 0:
cluster = shaped.run.clusters[cluster_index]
if not _cluster_may_have_no_outline(cluster):
codepoints = " ".join(
f"U+{ord(character):04X}"
for character in cluster
)
raise ValueError(
f"{_element_label(element)} glyph for {codepoints} has "
"no vector outline; bitmap/color-only glyphs cannot be "
"used in Shape Boolean"
)
glyph_cursor_x += position.x_advance * shaped.scale
glyph_cursor_y += position.y_advance * shaped.scale
natural_run_x += shaped.advance_x
if not commands:
raise ValueError(
f"{_element_label(element)} has no glyph outline to materialize"
)
return normalize_path_commands(commands)
@lru_cache(maxsize=1)
def _load_harfbuzz() -> Any:
try:
import uharfbuzz
except ImportError as exc:
raise RuntimeError(
"Text operands in Shape Boolean require uharfbuzz. Install the "
"project requirements, or run: pip install uharfbuzz"
) from exc
required = ("Blob", "Buffer", "DrawFuncs", "Face", "Font", "StyleTag", "shape")
missing = [name for name in required if not hasattr(uharfbuzz, name)]
if missing:
raise RuntimeError(
"Installed uharfbuzz is incompatible; missing: "
+ ", ".join(missing)
)
return uharfbuzz
def _element_chain(
element: ET.Element,
parents: Mapping[ET.Element, ET.Element],
) -> list[ET.Element]:
chain = [element]
current = element
while current in parents:
current = parents[current]
chain.append(current)
chain.reverse()
return chain
def _validate_text_structure(
element: ET.Element,
chain: Sequence[ET.Element],
) -> None:
if _local_name(element.tag) != "text":
raise AssertionError("Text outline requires one <text> element")
children = list(element)
if children:
child_tags = ", ".join(
f"<{_local_name(child.tag)}>"
for child in children
)
raise ValueError(
f"{_element_label(element)} must contain direct single-line text; "
f"child content is unsupported ({child_tags})"
)
unsupported_attrs = sorted(
_local_name(name)
for name in element.attrib
if _local_name(name) in _UNSUPPORTED_TEXT_ATTRIBUTES
)
if unsupported_attrs:
raise ValueError(
f"{_element_label(element)} uses unsupported text attribute(s): "
+ ", ".join(unsupported_attrs)
)
for current in chain:
inline = parse_inline_style(current.get("style"))
unsupported = sorted(
name
for name in _UNSUPPORTED_TEXT_PROPERTIES
if current.get(name) is not None or name in inline
)
if unsupported:
raise ValueError(
f"{_element_label(element)} inherits unsupported text "
f"property/properties from {_element_label(current)}: "
+ ", ".join(unsupported)
)
decoration = _effective_property(chain, "text-decoration") or "none"
try:
parsed_decoration = parse_project_text_decoration(decoration)
except ValueError as exc:
raise ValueError(
f"{_element_label(element)} text-decoration={decoration!r}: {exc}"
) from exc
if parsed_decoration.canonical != "none":
raise ValueError(
f"{_element_label(element)} uses text decoration; materialize the "
"decoration separately before Shape Boolean"
)
stroke = _effective_property(chain, "stroke")
if stroke is not None and stroke.strip().lower() not in {
"",
"inherit",
"none",
"transparent",
}:
raise ValueError(
f"{_element_label(element)} uses a visible text stroke; Shape "
"Boolean materializes the filled glyph silhouette only"
)
def _normalized_direct_text(
element: ET.Element,
chain: Sequence[ET.Element],
) -> str:
xml_space = "default"
for current in chain:
try:
xml_space = resolve_project_xml_space(current, xml_space)
except ValueError as exc:
raise ValueError(
f"{_element_label(current)} xml:space: {exc}"
) from exc
segments = normalize_project_text_segments([
(xml_space, element.text or ""),
])
return "".join(text for _owner, text in segments)
def _resolve_text_style(chain: Sequence[ET.Element]) -> _TextStyle:
font_size = 16.0
root_font_size = 16.0
letter_spacing = 0.0
family_value = "sans-serif"
weight_value = "normal"
style_value = "normal"
anchor_value = "start"
for index, current in enumerate(chain):
inline = parse_inline_style(current.get("style"))
raw_size = inline.get("font-size", current.get("font-size"))
if raw_size is not None and raw_size.strip().lower() != "inherit":
relative_base = (
root_font_size
if raw_size.strip().lower().endswith("rem")
else font_size
)
try:
font_size = parse_svg_length(
raw_size,
font_size,
font_size=relative_base,
)
font_px_to_hpt(font_size)
except ValueError as exc:
raise ValueError(
f"{_element_label(current)} font-size={raw_size!r}: {exc}"
) from exc
if index == 0:
root_font_size = font_size
raw_spacing = inline.get(
"letter-spacing",
current.get("letter-spacing"),
)
if raw_spacing is not None and raw_spacing.strip().lower() != "inherit":
try:
letter_spacing = float(parse_project_letter_spacing(
raw_spacing,
font_size=font_size,
).value)
except ValueError as exc:
raise ValueError(
f"{_element_label(current)} "
f"letter-spacing={raw_spacing!r}: {exc}"
) from exc
family_value = _updated_property(
current,
inline,
"font-family",
family_value,
)
weight_value = _updated_property(
current,
inline,
"font-weight",
weight_value,
)
style_value = _updated_property(
current,
inline,
"font-style",
style_value,
)
anchor_value = _updated_property(
current,
inline,
"text-anchor",
anchor_value,
)
families = _parse_font_family_stack(family_value)
try:
parsed_weight = parse_project_font_weight(weight_value)
parsed_style = parse_project_font_style(style_value)
parsed_anchor = parse_project_text_anchor(anchor_value)
except ValueError as exc:
raise ValueError(
f"{_element_label(chain[-1])} has invalid text typography: {exc}"
) from exc
weight = {
"normal": 400,
"bold": 700,
}.get(parsed_weight.canonical)
if weight is None:
weight = int(parsed_weight.canonical)
return _TextStyle(
families=families,
font_size=font_size,
font_weight=weight,
italic=bool(parsed_style.value),
anchor=str(parsed_anchor.value),
letter_spacing=letter_spacing,
)
def _updated_property(
element: ET.Element,
inline: Mapping[str, str],
name: str,
inherited: str,
) -> str:
raw = inline.get(name, element.get(name))
if raw is None or raw.strip().lower() == "inherit":
return inherited
value = raw.strip()
if not value:
raise ValueError(f"{_element_label(element)} {name} must not be empty")
return value
def _effective_property(
chain: Sequence[ET.Element],
name: str,
) -> str | None:
value: str | None = None
for current in chain:
inline = parse_inline_style(current.get("style"))
raw = inline.get(name, current.get(name))
if raw is not None and raw.strip().lower() != "inherit":
value = raw.strip()
return value
def _text_position(element: ET.Element, name: str) -> float:
raw = element.get(name)
if raw is None:
return 0.0
try:
return parse_project_geometry_length(raw, name)
except ValueError as exc:
raise ValueError(
f"{_element_label(element)} {name}={raw!r}: {exc}"
) from exc
def _parse_font_family_stack(raw: str) -> tuple[str, ...]:
families = tuple(
value.strip().strip("'\"")
for value in raw.split(",")
)
if not families or any(not family for family in families):
raise ValueError("font-family must contain at least one family name")
return families
def _normalize_explicit_font_dirs(
font_dirs: Sequence[str | Path],
) -> tuple[str, ...]:
if isinstance(font_dirs, (str, bytes)):
raise ValueError(
"Shape Boolean font_dirs must be a sequence of directory paths"
)
roots: list[str] = []
seen: set[str] = set()
for raw in font_dirs:
root = Path(raw).expanduser()
if not root.is_dir():
raise ValueError(f"Shape Boolean font directory not found: {root}")
resolved = str(root.resolve())
if resolved not in seen:
roots.append(resolved)
seen.add(resolved)
return tuple(roots)
@lru_cache(maxsize=8)
def _font_catalog(explicit_roots: tuple[str, ...]) -> tuple[_FontFace, ...]:
hb = _load_harfbuzz()
records: list[_FontFace] = []
for path in _font_files(explicit_roots):
try:
data = path.read_bytes()
first_face = hb.Face(data, 0)
face_count = max(1, int(first_face.count))
except (OSError, RuntimeError, ValueError):
continue
for index in range(face_count):
try:
face = hb.Face(data, index)
families = _face_names(face, _FAMILY_NAME_IDS)
if not families:
continue
font = hb.Font(face)
weight_value = font.get_style_value(hb.StyleTag.WEIGHT)
italic_value = font.get_style_value(hb.StyleTag.ITALIC)
slant_value = font.get_style_value(hb.StyleTag.SLANT_ANGLE)
axes = {
axis.tag: axis
for axis in face.axis_infos
}
coverage = frozenset(int(value) for value in face.unicodes)
except (OSError, RuntimeError, TypeError, ValueError):
continue
weight = int(round(weight_value)) if math.isfinite(weight_value) else 400
weight = min(1000, max(1, weight))
italic = (
math.isfinite(italic_value) and italic_value >= 0.5
) or (
math.isfinite(slant_value) and abs(slant_value) > 0.01
)
records.append(_FontFace(
path=path,
index=index,
families=families,
weight=weight,
italic=italic,
weight_range=_axis_range(axes.get("wght")),
italic_range=_axis_range(axes.get("ital")),
coverage=coverage,
order=len(records),
))
if not records:
raise RuntimeError(
"Text operands in Shape Boolean found no readable OpenType fonts; "
"install fonts or pass --font-dir"
)
return tuple(records)
def _font_files(explicit_roots: tuple[str, ...]) -> tuple[Path, ...]:
roots = [Path(value) for value in explicit_roots]
roots.extend(_system_font_roots())
files: list[Path] = []
seen: set[str] = set()
for root in roots:
if not root.is_dir():
continue
try:
candidates = sorted(
(
path
for path in root.rglob("*")
if path.is_file()
and path.suffix.lower() in _FONT_EXTENSIONS
),
key=lambda path: str(path).casefold(),
)
except OSError:
continue
for path in candidates:
try:
key = str(path.resolve())
except OSError:
key = str(path)
if key not in seen:
files.append(path)
seen.add(key)
return tuple(files)
def _system_font_roots() -> tuple[Path, ...]:
roots: list[Path] = []
home = Path.home()
if sys.platform.startswith("win"):
windows = Path(os.environ.get("WINDIR", "C:/Windows"))
roots.append(windows / "Fonts")
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
roots.append(Path(local_app_data) / "Microsoft/Windows/Fonts")
elif sys.platform == "darwin":
roots.extend((
Path("/System/Library/Fonts"),
Path("/Library/Fonts"),
home / "Library/Fonts",
))
else:
xdg_data_home = os.environ.get("XDG_DATA_HOME")
if xdg_data_home:
roots.append(Path(xdg_data_home) / "fonts")
roots.extend((home / ".local/share/fonts", home / ".fonts"))
xdg_data_dirs = os.environ.get(
"XDG_DATA_DIRS",
"/usr/local/share:/usr/share",
)
roots.extend(
Path(value) / "fonts"
for value in xdg_data_dirs.split(os.pathsep)
if value
)
return tuple(roots)
def _face_names(face: Any, name_ids: Sequence[int]) -> tuple[str, ...]:
values: list[str] = []
seen: set[str] = set()
for name_id in name_ids:
value = face.get_name(name_id)
if not value:
continue
normalized = " ".join(str(value).split())
key = _family_key(normalized)
if normalized and key not in seen:
values.append(normalized)
seen.add(key)
return tuple(values)
def _font_candidates(
catalog: Sequence[_FontFace],
families: Sequence[str],
target_weight: int,
italic: bool,
) -> list[_FontFace]:
by_family: dict[str, list[_FontFace]] = {}
for face in catalog:
for family in face.families:
by_family.setdefault(_family_key(family), []).append(face)
candidates: list[_FontFace] = []
seen: set[tuple[Path, int]] = set()
for requested in families:
requested_key = _family_key(requested)
generic = requested_key in _GENERIC_FAMILIES
expanded = _GENERIC_FAMILIES.get(requested_key, (requested,))
for family in expanded:
available = by_family.get(_family_key(family), ())
if generic:
available = tuple(
face
for face in available
if _face_supports_style(
face,
target_weight,
italic,
)
)
matches = sorted(
available,
key=lambda face: (
not _face_supports_style(
face,
target_weight,
italic,
),
face.italic != italic,
abs(face.weight - target_weight),
face.order,
),
)
for face in matches:
key = (face.path, face.index)
if key not in seen:
candidates.append(face)
seen.add(key)
return candidates
def _face_supports_style(
face: _FontFace,
requested_weight: int,
requested_italic: bool,
) -> bool:
weight_supported = (
face.weight == requested_weight
or (
face.weight_range is not None
and face.weight_range[0]
<= requested_weight
<= face.weight_range[1]
)
)
italic_value = 1.0 if requested_italic else 0.0
italic_supported = (
face.italic == requested_italic
or (
face.italic_range is not None
and face.italic_range[0]
<= italic_value
<= face.italic_range[1]
)
)
return weight_supported and italic_supported
def _axis_range(axis: Any | None) -> tuple[float, float] | None:
if axis is None:
return None
return (float(axis.min_value), float(axis.max_value))
def _font_runs(
element: ET.Element,
clusters: Sequence[str],
candidates: Sequence[_FontFace],
families: Sequence[str],
) -> list[_FontRun]:
selected: list[tuple[str, _FontFace]] = []
previous_face: _FontFace | None = None
for cluster in clusters:
required = _required_codepoints(cluster)
face = next(
(
candidate
for candidate in candidates
if required.issubset(candidate.coverage)
),
None,
)
if face is None and not required and previous_face is not None:
face = previous_face
if face is None:
codepoints = " ".join(
f"U+{ord(character):04X}"
for character in cluster
)
raise ValueError(
f"{_element_label(element)} cannot resolve glyphs for "
f"{codepoints} from font stack {', '.join(families)!r}"
)
selected.append((cluster, face))
previous_face = face
runs: list[_FontRun] = []
run_clusters: list[str] = []
run_face: _FontFace | None = None
run_start = 0
for index, (cluster, face) in enumerate(selected):
if run_face is None or face is run_face:
run_clusters.append(cluster)
run_face = face
continue
runs.append(_FontRun(
face=run_face,
clusters=tuple(run_clusters),
global_cluster_start=run_start,
))
run_clusters = [cluster]
run_face = face
run_start = index
if run_face is not None:
runs.append(_FontRun(
face=run_face,
clusters=tuple(run_clusters),
global_cluster_start=run_start,
))
return runs
def _shape_runs(
runs: Sequence[_FontRun],
font_size: float,
font_weight: int,
italic: bool,
letter_spacing: float,
) -> list[_ShapedRun]:
hb = _load_harfbuzz()
font_cache: dict[tuple[Path, int], tuple[Any, int]] = {}
shaped: list[_ShapedRun] = []
for run in runs:
key = (run.face.path, run.face.index)
cached = font_cache.get(key)
if cached is None:
data = run.face.path.read_bytes()
face = hb.Face(data, run.face.index)
upem = int(face.upem)
if upem <= 0:
raise ValueError(
f"Font {run.face.path} face {run.face.index} has invalid UPEM"
)
font = hb.Font(face)
font.scale = (upem, upem)
_apply_font_style(
run.face,
face,
font,
font_weight,
italic,
)
cached = (font, upem)
font_cache[key] = cached
font, upem = cached
buffer = hb.Buffer()
buffer.add_str(run.text)
buffer.guess_segment_properties()
if str(buffer.direction) != "ltr":
raise ValueError(
f"Text run {run.text!r} resolves to {buffer.direction} "
"direction; only horizontal left-to-right text is supported"
)
if letter_spacing:
hb.shape(font, buffer, {"liga": False, "clig": False})
else:
hb.shape(font, buffer)
infos = tuple(buffer.glyph_infos)
positions = tuple(buffer.glyph_positions)
if not infos or len(infos) != len(positions):
raise ValueError(f"Font shaping produced no glyphs for {run.text!r}")
scale = font_size / upem
advance_x = sum(position.x_advance for position in positions) * scale
shaped.append(_ShapedRun(
run=run,
font=font,
infos=infos,
positions=positions,
scale=scale,
advance_x=advance_x,
))
return shaped
def _apply_font_style(
record: _FontFace,
face: Any,
font: Any,
requested_weight: int,
requested_italic: bool,
) -> None:
axes = {
axis.tag: axis
for axis in face.axis_infos
}
variations: dict[str, float] = {}
weight_axis = axes.get("wght")
if weight_axis is not None:
if not weight_axis.min_value <= requested_weight <= weight_axis.max_value:
raise ValueError(
f"Font {record.path.name} cannot provide requested weight "
f"{requested_weight}; its wght axis is "
f"{weight_axis.min_value:g}..{weight_axis.max_value:g}"
)
variations["wght"] = float(requested_weight)
elif record.weight != requested_weight:
raise ValueError(
f"Font {record.path.name} resolves weight {record.weight}, not "
f"requested {requested_weight}; install the exact face, choose a "
"matching family, or adjust the text operand"
)
italic_axis = axes.get("ital")
italic_value = 1.0 if requested_italic else 0.0
if italic_axis is not None:
if not italic_axis.min_value <= italic_value <= italic_axis.max_value:
style = "italic" if requested_italic else "normal"
raise ValueError(
f"Font {record.path.name} cannot provide requested {style} "
"style through its ital axis"
)
variations["ital"] = italic_value
elif record.italic != requested_italic:
requested = "italic" if requested_italic else "normal"
resolved = "italic" if record.italic else "normal"
raise ValueError(
f"Font {record.path.name} resolves {resolved}, not requested "
f"{requested}; install the exact face, choose a matching family, "
"or adjust the text operand"
)
if variations:
font.set_variations(variations)
def _draw_functions(hb: Any) -> Any:
functions = hb.DrawFuncs()
def move_to(x: float, y: float, state: _DrawState) -> None:
point = state.point(x, y)
state.commands.append(PathCommand("M", [*point]))
def line_to(x: float, y: float, state: _DrawState) -> None:
point = state.point(x, y)
state.commands.append(PathCommand("L", [*point]))
state.segment_count += 1
def quadratic_to(
control_x: float,
control_y: float,
x: float,
y: float,
state: _DrawState,
) -> None:
control = state.point(control_x, control_y)
point = state.point(x, y)
state.commands.append(PathCommand(
"Q",
[control[0], control[1], point[0], point[1]],
))
state.segment_count += 1
def cubic_to(
control_1_x: float,
control_1_y: float,
control_2_x: float,
control_2_y: float,
x: float,
y: float,
state: _DrawState,
) -> None:
control_1 = state.point(control_1_x, control_1_y)
control_2 = state.point(control_2_x, control_2_y)
point = state.point(x, y)
state.commands.append(PathCommand(
"C",
[
control_1[0],
control_1[1],
control_2[0],
control_2[1],
point[0],
point[1],
],
))
state.segment_count += 1
def close_path(state: _DrawState) -> None:
state.commands.append(PathCommand("Z", []))
functions.set_move_to_func(move_to)
functions.set_line_to_func(line_to)
functions.set_quadratic_to_func(quadratic_to)
functions.set_cubic_to_func(cubic_to)
functions.set_close_path_func(close_path)
return functions
def _cluster_offsets(clusters: Sequence[str]) -> list[int]:
offsets: list[int] = []
offset = 0
for cluster in clusters:
offsets.append(offset)
offset += len(cluster)
return offsets
def _required_codepoints(cluster: str) -> frozenset[int]:
return frozenset(
ord(character)
for character in cluster
if not _is_default_ignorable(character)
)
def _is_default_ignorable(character: str) -> bool:
codepoint = ord(character)
return (
unicodedata.category(character) == "Cf"
or 0x180B <= codepoint <= 0x180D
or 0x2061 <= codepoint <= 0x206F
or 0xFE00 <= codepoint <= 0xFE0F
or 0xE0100 <= codepoint <= 0xE01EF
)
def _cluster_may_have_no_outline(cluster: str) -> bool:
return all(
character.isspace() or _is_default_ignorable(character)
for character in cluster
)
def _family_key(value: str) -> str:
return " ".join(value.split()).casefold()
def _local_name(tag: object) -> str:
raw = str(tag)
return raw.rsplit("}", 1)[-1] if "}" in raw else raw
def _element_label(element: ET.Element) -> str:
tag = _local_name(element.tag)
element_id = element.get("id")
return f"<{tag} id={element_id!r}>" if element_id else f"<{tag}>"