返回 ppt-master
text_outline.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / text_outline.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Text Outline Materialization
4
5 Resolve one supported SVG ``<text>`` operand into closed glyph-outline path
6 commands for the Shape Boolean geometry pipeline.
7
8 Usage:
9 Import text_element_to_path_commands from svg_to_pptx.text_outline.
10
11 Dependencies:
12 uharfbuzz and local PPT Master modules
13 """
14
15 from __future__ import annotations
16
17 import bisect
18 import math
19 import os
20 import sys
21 import unicodedata
22 from collections.abc import Mapping, Sequence
23 from dataclasses import dataclass
24 from functools import lru_cache
25 from pathlib import Path
26 from typing import Any
27 from xml.etree import ElementTree as ET
28
29 from .drawingml.paths import PathCommand, normalize_path_commands
30 from .drawingml.text_properties import (
31 normalize_project_text_segments,
32 parse_project_font_style,
33 parse_project_font_weight,
34 parse_project_letter_spacing,
35 parse_project_text_anchor,
36 parse_project_text_decoration,
37 resolve_project_xml_space,
38 )
39 from .drawingml.utils import (
40 font_px_to_hpt,
41 parse_inline_style,
42 parse_project_geometry_length,
43 parse_svg_length,
44 split_project_text_clusters,
45 )
46
47
48 _FONT_EXTENSIONS = frozenset({".otc", ".otf", ".ttc", ".ttf"})
49 _FAMILY_NAME_IDS = (16, 1, 21)
50 _GENERIC_FAMILIES = {
51 "sans-serif": (
52 "Segoe UI",
53 "Arial",
54 "Helvetica",
55 "Noto Sans",
56 "Liberation Sans",
57 "DejaVu Sans",
58 "Nimbus Sans",
59 "Noto Sans CJK SC",
60 "Source Han Sans SC",
61 "Droid Sans Fallback",
62 ),
63 "system-ui": (
64 "Segoe UI",
65 "SF Pro",
66 "Helvetica Neue",
67 "Noto Sans",
68 "Liberation Sans",
69 "DejaVu Sans",
70 "Noto Sans CJK SC",
71 "Droid Sans Fallback",
72 ),
73 "serif": (
74 "Times New Roman",
75 "Noto Serif",
76 "Liberation Serif",
77 "DejaVu Serif",
78 "Nimbus Roman",
79 "Noto Serif CJK SC",
80 "Source Han Serif SC",
81 ),
82 "monospace": (
83 "Consolas",
84 "Courier New",
85 "Noto Sans Mono",
86 "Liberation Mono",
87 "DejaVu Sans Mono",
88 "Nimbus Mono PS",
89 "Noto Sans Mono CJK SC",
90 ),
91 }
92 _GENERIC_FAMILIES["ui-sans-serif"] = _GENERIC_FAMILIES["system-ui"]
93 _GENERIC_FAMILIES["ui-serif"] = _GENERIC_FAMILIES["serif"]
94 _GENERIC_FAMILIES["ui-monospace"] = _GENERIC_FAMILIES["monospace"]
95 _UNSUPPORTED_TEXT_PROPERTIES = frozenset({
96 "alignment-baseline",
97 "baseline-shift",
98 "direction",
99 "dominant-baseline",
100 "font-feature-settings",
101 "font-kerning",
102 "font-size-adjust",
103 "font-stretch",
104 "font-synthesis",
105 "font-variant",
106 "font-variation-settings",
107 "font",
108 "hyphens",
109 "kerning",
110 "line-height",
111 "overflow-wrap",
112 "text-align",
113 "text-align-last",
114 "text-indent",
115 "text-orientation",
116 "text-rendering",
117 "text-transform",
118 "unicode-bidi",
119 "vertical-align",
120 "white-space",
121 "word-spacing",
122 "word-break",
123 "writing-mode",
124 })
125 _UNSUPPORTED_TEXT_ATTRIBUTES = frozenset({
126 "dx",
127 "dy",
128 "lengthAdjust",
129 "rotate",
130 "textLength",
131 })
132 _UNSUPPORTED_BIDI_CLASSES = frozenset({
133 "AL",
134 "FSI",
135 "LRE",
136 "LRI",
137 "LRO",
138 "PDF",
139 "PDI",
140 "R",
141 "RLE",
142 "RLI",
143 "RLO",
144 })
145 @dataclass(frozen=True)
146 class _FontFace:
147 path: Path
148 index: int
149 families: tuple[str, ...]
150 weight: int
151 italic: bool
152 weight_range: tuple[float, float] | None
153 italic_range: tuple[float, float] | None
154 coverage: frozenset[int]
155 order: int
156
157
158 @dataclass(frozen=True)
159 class _TextStyle:
160 families: tuple[str, ...]
161 font_size: float
162 font_weight: int
163 italic: bool
164 anchor: str
165 letter_spacing: float
166
167
168 @dataclass(frozen=True)
169 class _FontRun:
170 face: _FontFace
171 clusters: tuple[str, ...]
172 global_cluster_start: int
173
174 @property
175 def text(self) -> str:
176 return "".join(self.clusters)
177
178
179 @dataclass
180 class _ShapedRun:
181 run: _FontRun
182 font: Any
183 infos: Sequence[Any]
184 positions: Sequence[Any]
185 scale: float
186 advance_x: float
187
188
189 @dataclass
190 class _DrawState:
191 origin_x: float
192 origin_y: float
193 scale: float
194 commands: list[PathCommand]
195 segment_count: int = 0
196
197 def point(self, x: float, y: float) -> tuple[float, float]:
198 return (
199 self.origin_x + x * self.scale,
200 self.origin_y - y * self.scale,
201 )
202
203
204 def text_element_to_path_commands(
205 element: ET.Element,
206 parents: Mapping[ET.Element, ET.Element],
207 *,
208 font_dirs: Sequence[str | Path] = (),
209 ) -> list[PathCommand]:
210 """Return closed glyph outlines for one supported SVG ``<text>`` element."""
211 chain = _element_chain(element, parents)
212 _validate_text_structure(element, chain)
213 text = _normalized_direct_text(element, chain)
214 if not text:
215 raise ValueError(f"{_element_label(element)} has no rendered text")
216 if any(
217 unicodedata.bidirectional(character)
218 in _UNSUPPORTED_BIDI_CLASSES
219 for character in text
220 ):
221 raise ValueError(
222 f"{_element_label(element)} uses right-to-left or bidirectional "
223 "text; materialize its glyph outlines before Shape Boolean"
224 )
225
226 style = _resolve_text_style(chain)
227 x = _text_position(element, "x")
228 y = _text_position(element, "y")
229 explicit_roots = _normalize_explicit_font_dirs(font_dirs)
230 catalog = _font_catalog(explicit_roots)
231 candidates = _font_candidates(
232 catalog,
233 style.families,
234 style.font_weight,
235 style.italic,
236 )
237 if not candidates:
238 family_list = ", ".join(style.families)
239 raise ValueError(
240 f"{_element_label(element)} cannot resolve an installed font from "
241 f"{family_list!r}; add a usable fallback or pass --font-dir"
242 )
243
244 clusters = split_project_text_clusters(text)
245 runs = _font_runs(
246 element,
247 clusters,
248 candidates,
249 style.families,
250 )
251 shaped_runs = _shape_runs(
252 runs,
253 style.font_size,
254 style.font_weight,
255 style.italic,
256 style.letter_spacing,
257 )
258 natural_width = sum(run.advance_x for run in shaped_runs)
259 tracked_width = (
260 natural_width
261 + max(0, len(clusters) - 1) * style.letter_spacing
262 )
263 if not math.isfinite(tracked_width) or tracked_width <= 0:
264 raise ValueError(
265 f"{_element_label(element)} resolves to a non-positive text width"
266 )
267 anchor_shift = {
268 "start": 0.0,
269 "middle": -tracked_width / 2.0,
270 "end": -tracked_width,
271 }[style.anchor]
272
273 hb = _load_harfbuzz()
274 draw_funcs = _draw_functions(hb)
275 commands: list[PathCommand] = []
276 natural_run_x = 0.0
277 for shaped in shaped_runs:
278 run_offsets = _cluster_offsets(shaped.run.clusters)
279 glyph_cursor_x = 0.0
280 glyph_cursor_y = 0.0
281 for info, position in zip(shaped.infos, shaped.positions):
282 if info.codepoint == 0:
283 raise ValueError(
284 f"{_element_label(element)} shaped a missing glyph in "
285 f"{shaped.run.face.path.name}"
286 )
287 cluster_index = max(
288 0,
289 bisect.bisect_right(run_offsets, info.cluster) - 1,
290 )
291 cluster_index = min(
292 cluster_index,
293 len(shaped.run.clusters) - 1,
294 )
295 global_cluster = (
296 shaped.run.global_cluster_start + cluster_index
297 )
298 spacing_shift = global_cluster * style.letter_spacing
299 state = _DrawState(
300 origin_x=(
301 x
302 + anchor_shift
303 + natural_run_x
304 + glyph_cursor_x
305 + position.x_offset * shaped.scale
306 + spacing_shift
307 ),
308 origin_y=(
309 y
310 - glyph_cursor_y
311 - position.y_offset * shaped.scale
312 ),
313 scale=shaped.scale,
314 commands=commands,
315 )
316 shaped.font.draw_glyph(info.codepoint, draw_funcs, state)
317 if state.segment_count == 0:
318 cluster = shaped.run.clusters[cluster_index]
319 if not _cluster_may_have_no_outline(cluster):
320 codepoints = " ".join(
321 f"U+{ord(character):04X}"
322 for character in cluster
323 )
324 raise ValueError(
325 f"{_element_label(element)} glyph for {codepoints} has "
326 "no vector outline; bitmap/color-only glyphs cannot be "
327 "used in Shape Boolean"
328 )
329 glyph_cursor_x += position.x_advance * shaped.scale
330 glyph_cursor_y += position.y_advance * shaped.scale
331 natural_run_x += shaped.advance_x
332
333 if not commands:
334 raise ValueError(
335 f"{_element_label(element)} has no glyph outline to materialize"
336 )
337 return normalize_path_commands(commands)
338
339
340 @lru_cache(maxsize=1)
341 def _load_harfbuzz() -> Any:
342 try:
343 import uharfbuzz
344 except ImportError as exc:
345 raise RuntimeError(
346 "Text operands in Shape Boolean require uharfbuzz. Install the "
347 "project requirements, or run: pip install uharfbuzz"
348 ) from exc
349 required = ("Blob", "Buffer", "DrawFuncs", "Face", "Font", "StyleTag", "shape")
350 missing = [name for name in required if not hasattr(uharfbuzz, name)]
351 if missing:
352 raise RuntimeError(
353 "Installed uharfbuzz is incompatible; missing: "
354 + ", ".join(missing)
355 )
356 return uharfbuzz
357
358
359 def _element_chain(
360 element: ET.Element,
361 parents: Mapping[ET.Element, ET.Element],
362 ) -> list[ET.Element]:
363 chain = [element]
364 current = element
365 while current in parents:
366 current = parents[current]
367 chain.append(current)
368 chain.reverse()
369 return chain
370
371
372 def _validate_text_structure(
373 element: ET.Element,
374 chain: Sequence[ET.Element],
375 ) -> None:
376 if _local_name(element.tag) != "text":
377 raise AssertionError("Text outline requires one <text> element")
378 children = list(element)
379 if children:
380 child_tags = ", ".join(
381 f"<{_local_name(child.tag)}>"
382 for child in children
383 )
384 raise ValueError(
385 f"{_element_label(element)} must contain direct single-line text; "
386 f"child content is unsupported ({child_tags})"
387 )
388 unsupported_attrs = sorted(
389 _local_name(name)
390 for name in element.attrib
391 if _local_name(name) in _UNSUPPORTED_TEXT_ATTRIBUTES
392 )
393 if unsupported_attrs:
394 raise ValueError(
395 f"{_element_label(element)} uses unsupported text attribute(s): "
396 + ", ".join(unsupported_attrs)
397 )
398
399 for current in chain:
400 inline = parse_inline_style(current.get("style"))
401 unsupported = sorted(
402 name
403 for name in _UNSUPPORTED_TEXT_PROPERTIES
404 if current.get(name) is not None or name in inline
405 )
406 if unsupported:
407 raise ValueError(
408 f"{_element_label(element)} inherits unsupported text "
409 f"property/properties from {_element_label(current)}: "
410 + ", ".join(unsupported)
411 )
412
413 decoration = _effective_property(chain, "text-decoration") or "none"
414 try:
415 parsed_decoration = parse_project_text_decoration(decoration)
416 except ValueError as exc:
417 raise ValueError(
418 f"{_element_label(element)} text-decoration={decoration!r}: {exc}"
419 ) from exc
420 if parsed_decoration.canonical != "none":
421 raise ValueError(
422 f"{_element_label(element)} uses text decoration; materialize the "
423 "decoration separately before Shape Boolean"
424 )
425
426 stroke = _effective_property(chain, "stroke")
427 if stroke is not None and stroke.strip().lower() not in {
428 "",
429 "inherit",
430 "none",
431 "transparent",
432 }:
433 raise ValueError(
434 f"{_element_label(element)} uses a visible text stroke; Shape "
435 "Boolean materializes the filled glyph silhouette only"
436 )
437
438
439 def _normalized_direct_text(
440 element: ET.Element,
441 chain: Sequence[ET.Element],
442 ) -> str:
443 xml_space = "default"
444 for current in chain:
445 try:
446 xml_space = resolve_project_xml_space(current, xml_space)
447 except ValueError as exc:
448 raise ValueError(
449 f"{_element_label(current)} xml:space: {exc}"
450 ) from exc
451 segments = normalize_project_text_segments([
452 (xml_space, element.text or ""),
453 ])
454 return "".join(text for _owner, text in segments)
455
456
457 def _resolve_text_style(chain: Sequence[ET.Element]) -> _TextStyle:
458 font_size = 16.0
459 root_font_size = 16.0
460 letter_spacing = 0.0
461 family_value = "sans-serif"
462 weight_value = "normal"
463 style_value = "normal"
464 anchor_value = "start"
465
466 for index, current in enumerate(chain):
467 inline = parse_inline_style(current.get("style"))
468 raw_size = inline.get("font-size", current.get("font-size"))
469 if raw_size is not None and raw_size.strip().lower() != "inherit":
470 relative_base = (
471 root_font_size
472 if raw_size.strip().lower().endswith("rem")
473 else font_size
474 )
475 try:
476 font_size = parse_svg_length(
477 raw_size,
478 font_size,
479 font_size=relative_base,
480 )
481 font_px_to_hpt(font_size)
482 except ValueError as exc:
483 raise ValueError(
484 f"{_element_label(current)} font-size={raw_size!r}: {exc}"
485 ) from exc
486 if index == 0:
487 root_font_size = font_size
488
489 raw_spacing = inline.get(
490 "letter-spacing",
491 current.get("letter-spacing"),
492 )
493 if raw_spacing is not None and raw_spacing.strip().lower() != "inherit":
494 try:
495 letter_spacing = float(parse_project_letter_spacing(
496 raw_spacing,
497 font_size=font_size,
498 ).value)
499 except ValueError as exc:
500 raise ValueError(
501 f"{_element_label(current)} "
502 f"letter-spacing={raw_spacing!r}: {exc}"
503 ) from exc
504
505 family_value = _updated_property(
506 current,
507 inline,
508 "font-family",
509 family_value,
510 )
511 weight_value = _updated_property(
512 current,
513 inline,
514 "font-weight",
515 weight_value,
516 )
517 style_value = _updated_property(
518 current,
519 inline,
520 "font-style",
521 style_value,
522 )
523 anchor_value = _updated_property(
524 current,
525 inline,
526 "text-anchor",
527 anchor_value,
528 )
529
530 families = _parse_font_family_stack(family_value)
531 try:
532 parsed_weight = parse_project_font_weight(weight_value)
533 parsed_style = parse_project_font_style(style_value)
534 parsed_anchor = parse_project_text_anchor(anchor_value)
535 except ValueError as exc:
536 raise ValueError(
537 f"{_element_label(chain[-1])} has invalid text typography: {exc}"
538 ) from exc
539 weight = {
540 "normal": 400,
541 "bold": 700,
542 }.get(parsed_weight.canonical)
543 if weight is None:
544 weight = int(parsed_weight.canonical)
545 return _TextStyle(
546 families=families,
547 font_size=font_size,
548 font_weight=weight,
549 italic=bool(parsed_style.value),
550 anchor=str(parsed_anchor.value),
551 letter_spacing=letter_spacing,
552 )
553
554
555 def _updated_property(
556 element: ET.Element,
557 inline: Mapping[str, str],
558 name: str,
559 inherited: str,
560 ) -> str:
561 raw = inline.get(name, element.get(name))
562 if raw is None or raw.strip().lower() == "inherit":
563 return inherited
564 value = raw.strip()
565 if not value:
566 raise ValueError(f"{_element_label(element)} {name} must not be empty")
567 return value
568
569
570 def _effective_property(
571 chain: Sequence[ET.Element],
572 name: str,
573 ) -> str | None:
574 value: str | None = None
575 for current in chain:
576 inline = parse_inline_style(current.get("style"))
577 raw = inline.get(name, current.get(name))
578 if raw is not None and raw.strip().lower() != "inherit":
579 value = raw.strip()
580 return value
581
582
583 def _text_position(element: ET.Element, name: str) -> float:
584 raw = element.get(name)
585 if raw is None:
586 return 0.0
587 try:
588 return parse_project_geometry_length(raw, name)
589 except ValueError as exc:
590 raise ValueError(
591 f"{_element_label(element)} {name}={raw!r}: {exc}"
592 ) from exc
593
594
595 def _parse_font_family_stack(raw: str) -> tuple[str, ...]:
596 families = tuple(
597 value.strip().strip("'\"")
598 for value in raw.split(",")
599 )
600 if not families or any(not family for family in families):
601 raise ValueError("font-family must contain at least one family name")
602 return families
603
604
605 def _normalize_explicit_font_dirs(
606 font_dirs: Sequence[str | Path],
607 ) -> tuple[str, ...]:
608 if isinstance(font_dirs, (str, bytes)):
609 raise ValueError(
610 "Shape Boolean font_dirs must be a sequence of directory paths"
611 )
612 roots: list[str] = []
613 seen: set[str] = set()
614 for raw in font_dirs:
615 root = Path(raw).expanduser()
616 if not root.is_dir():
617 raise ValueError(f"Shape Boolean font directory not found: {root}")
618 resolved = str(root.resolve())
619 if resolved not in seen:
620 roots.append(resolved)
621 seen.add(resolved)
622 return tuple(roots)
623
624
625 @lru_cache(maxsize=8)
626 def _font_catalog(explicit_roots: tuple[str, ...]) -> tuple[_FontFace, ...]:
627 hb = _load_harfbuzz()
628 records: list[_FontFace] = []
629 for path in _font_files(explicit_roots):
630 try:
631 data = path.read_bytes()
632 first_face = hb.Face(data, 0)
633 face_count = max(1, int(first_face.count))
634 except (OSError, RuntimeError, ValueError):
635 continue
636 for index in range(face_count):
637 try:
638 face = hb.Face(data, index)
639 families = _face_names(face, _FAMILY_NAME_IDS)
640 if not families:
641 continue
642 font = hb.Font(face)
643 weight_value = font.get_style_value(hb.StyleTag.WEIGHT)
644 italic_value = font.get_style_value(hb.StyleTag.ITALIC)
645 slant_value = font.get_style_value(hb.StyleTag.SLANT_ANGLE)
646 axes = {
647 axis.tag: axis
648 for axis in face.axis_infos
649 }
650 coverage = frozenset(int(value) for value in face.unicodes)
651 except (OSError, RuntimeError, TypeError, ValueError):
652 continue
653 weight = int(round(weight_value)) if math.isfinite(weight_value) else 400
654 weight = min(1000, max(1, weight))
655 italic = (
656 math.isfinite(italic_value) and italic_value >= 0.5
657 ) or (
658 math.isfinite(slant_value) and abs(slant_value) > 0.01
659 )
660 records.append(_FontFace(
661 path=path,
662 index=index,
663 families=families,
664 weight=weight,
665 italic=italic,
666 weight_range=_axis_range(axes.get("wght")),
667 italic_range=_axis_range(axes.get("ital")),
668 coverage=coverage,
669 order=len(records),
670 ))
671 if not records:
672 raise RuntimeError(
673 "Text operands in Shape Boolean found no readable OpenType fonts; "
674 "install fonts or pass --font-dir"
675 )
676 return tuple(records)
677
678
679 def _font_files(explicit_roots: tuple[str, ...]) -> tuple[Path, ...]:
680 roots = [Path(value) for value in explicit_roots]
681 roots.extend(_system_font_roots())
682 files: list[Path] = []
683 seen: set[str] = set()
684 for root in roots:
685 if not root.is_dir():
686 continue
687 try:
688 candidates = sorted(
689 (
690 path
691 for path in root.rglob("*")
692 if path.is_file()
693 and path.suffix.lower() in _FONT_EXTENSIONS
694 ),
695 key=lambda path: str(path).casefold(),
696 )
697 except OSError:
698 continue
699 for path in candidates:
700 try:
701 key = str(path.resolve())
702 except OSError:
703 key = str(path)
704 if key not in seen:
705 files.append(path)
706 seen.add(key)
707 return tuple(files)
708
709
710 def _system_font_roots() -> tuple[Path, ...]:
711 roots: list[Path] = []
712 home = Path.home()
713 if sys.platform.startswith("win"):
714 windows = Path(os.environ.get("WINDIR", "C:/Windows"))
715 roots.append(windows / "Fonts")
716 local_app_data = os.environ.get("LOCALAPPDATA")
717 if local_app_data:
718 roots.append(Path(local_app_data) / "Microsoft/Windows/Fonts")
719 elif sys.platform == "darwin":
720 roots.extend((
721 Path("/System/Library/Fonts"),
722 Path("/Library/Fonts"),
723 home / "Library/Fonts",
724 ))
725 else:
726 xdg_data_home = os.environ.get("XDG_DATA_HOME")
727 if xdg_data_home:
728 roots.append(Path(xdg_data_home) / "fonts")
729 roots.extend((home / ".local/share/fonts", home / ".fonts"))
730 xdg_data_dirs = os.environ.get(
731 "XDG_DATA_DIRS",
732 "/usr/local/share:/usr/share",
733 )
734 roots.extend(
735 Path(value) / "fonts"
736 for value in xdg_data_dirs.split(os.pathsep)
737 if value
738 )
739 return tuple(roots)
740
741
742 def _face_names(face: Any, name_ids: Sequence[int]) -> tuple[str, ...]:
743 values: list[str] = []
744 seen: set[str] = set()
745 for name_id in name_ids:
746 value = face.get_name(name_id)
747 if not value:
748 continue
749 normalized = " ".join(str(value).split())
750 key = _family_key(normalized)
751 if normalized and key not in seen:
752 values.append(normalized)
753 seen.add(key)
754 return tuple(values)
755
756
757 def _font_candidates(
758 catalog: Sequence[_FontFace],
759 families: Sequence[str],
760 target_weight: int,
761 italic: bool,
762 ) -> list[_FontFace]:
763 by_family: dict[str, list[_FontFace]] = {}
764 for face in catalog:
765 for family in face.families:
766 by_family.setdefault(_family_key(family), []).append(face)
767
768 candidates: list[_FontFace] = []
769 seen: set[tuple[Path, int]] = set()
770 for requested in families:
771 requested_key = _family_key(requested)
772 generic = requested_key in _GENERIC_FAMILIES
773 expanded = _GENERIC_FAMILIES.get(requested_key, (requested,))
774 for family in expanded:
775 available = by_family.get(_family_key(family), ())
776 if generic:
777 available = tuple(
778 face
779 for face in available
780 if _face_supports_style(
781 face,
782 target_weight,
783 italic,
784 )
785 )
786 matches = sorted(
787 available,
788 key=lambda face: (
789 not _face_supports_style(
790 face,
791 target_weight,
792 italic,
793 ),
794 face.italic != italic,
795 abs(face.weight - target_weight),
796 face.order,
797 ),
798 )
799 for face in matches:
800 key = (face.path, face.index)
801 if key not in seen:
802 candidates.append(face)
803 seen.add(key)
804 return candidates
805
806
807 def _face_supports_style(
808 face: _FontFace,
809 requested_weight: int,
810 requested_italic: bool,
811 ) -> bool:
812 weight_supported = (
813 face.weight == requested_weight
814 or (
815 face.weight_range is not None
816 and face.weight_range[0]
817 <= requested_weight
818 <= face.weight_range[1]
819 )
820 )
821 italic_value = 1.0 if requested_italic else 0.0
822 italic_supported = (
823 face.italic == requested_italic
824 or (
825 face.italic_range is not None
826 and face.italic_range[0]
827 <= italic_value
828 <= face.italic_range[1]
829 )
830 )
831 return weight_supported and italic_supported
832
833
834 def _axis_range(axis: Any | None) -> tuple[float, float] | None:
835 if axis is None:
836 return None
837 return (float(axis.min_value), float(axis.max_value))
838
839
840 def _font_runs(
841 element: ET.Element,
842 clusters: Sequence[str],
843 candidates: Sequence[_FontFace],
844 families: Sequence[str],
845 ) -> list[_FontRun]:
846 selected: list[tuple[str, _FontFace]] = []
847 previous_face: _FontFace | None = None
848 for cluster in clusters:
849 required = _required_codepoints(cluster)
850 face = next(
851 (
852 candidate
853 for candidate in candidates
854 if required.issubset(candidate.coverage)
855 ),
856 None,
857 )
858 if face is None and not required and previous_face is not None:
859 face = previous_face
860 if face is None:
861 codepoints = " ".join(
862 f"U+{ord(character):04X}"
863 for character in cluster
864 )
865 raise ValueError(
866 f"{_element_label(element)} cannot resolve glyphs for "
867 f"{codepoints} from font stack {', '.join(families)!r}"
868 )
869 selected.append((cluster, face))
870 previous_face = face
871
872 runs: list[_FontRun] = []
873 run_clusters: list[str] = []
874 run_face: _FontFace | None = None
875 run_start = 0
876 for index, (cluster, face) in enumerate(selected):
877 if run_face is None or face is run_face:
878 run_clusters.append(cluster)
879 run_face = face
880 continue
881 runs.append(_FontRun(
882 face=run_face,
883 clusters=tuple(run_clusters),
884 global_cluster_start=run_start,
885 ))
886 run_clusters = [cluster]
887 run_face = face
888 run_start = index
889 if run_face is not None:
890 runs.append(_FontRun(
891 face=run_face,
892 clusters=tuple(run_clusters),
893 global_cluster_start=run_start,
894 ))
895 return runs
896
897
898 def _shape_runs(
899 runs: Sequence[_FontRun],
900 font_size: float,
901 font_weight: int,
902 italic: bool,
903 letter_spacing: float,
904 ) -> list[_ShapedRun]:
905 hb = _load_harfbuzz()
906 font_cache: dict[tuple[Path, int], tuple[Any, int]] = {}
907 shaped: list[_ShapedRun] = []
908 for run in runs:
909 key = (run.face.path, run.face.index)
910 cached = font_cache.get(key)
911 if cached is None:
912 data = run.face.path.read_bytes()
913 face = hb.Face(data, run.face.index)
914 upem = int(face.upem)
915 if upem <= 0:
916 raise ValueError(
917 f"Font {run.face.path} face {run.face.index} has invalid UPEM"
918 )
919 font = hb.Font(face)
920 font.scale = (upem, upem)
921 _apply_font_style(
922 run.face,
923 face,
924 font,
925 font_weight,
926 italic,
927 )
928 cached = (font, upem)
929 font_cache[key] = cached
930 font, upem = cached
931 buffer = hb.Buffer()
932 buffer.add_str(run.text)
933 buffer.guess_segment_properties()
934 if str(buffer.direction) != "ltr":
935 raise ValueError(
936 f"Text run {run.text!r} resolves to {buffer.direction} "
937 "direction; only horizontal left-to-right text is supported"
938 )
939 if letter_spacing:
940 hb.shape(font, buffer, {"liga": False, "clig": False})
941 else:
942 hb.shape(font, buffer)
943 infos = tuple(buffer.glyph_infos)
944 positions = tuple(buffer.glyph_positions)
945 if not infos or len(infos) != len(positions):
946 raise ValueError(f"Font shaping produced no glyphs for {run.text!r}")
947 scale = font_size / upem
948 advance_x = sum(position.x_advance for position in positions) * scale
949 shaped.append(_ShapedRun(
950 run=run,
951 font=font,
952 infos=infos,
953 positions=positions,
954 scale=scale,
955 advance_x=advance_x,
956 ))
957 return shaped
958
959
960 def _apply_font_style(
961 record: _FontFace,
962 face: Any,
963 font: Any,
964 requested_weight: int,
965 requested_italic: bool,
966 ) -> None:
967 axes = {
968 axis.tag: axis
969 for axis in face.axis_infos
970 }
971 variations: dict[str, float] = {}
972 weight_axis = axes.get("wght")
973 if weight_axis is not None:
974 if not weight_axis.min_value <= requested_weight <= weight_axis.max_value:
975 raise ValueError(
976 f"Font {record.path.name} cannot provide requested weight "
977 f"{requested_weight}; its wght axis is "
978 f"{weight_axis.min_value:g}..{weight_axis.max_value:g}"
979 )
980 variations["wght"] = float(requested_weight)
981 elif record.weight != requested_weight:
982 raise ValueError(
983 f"Font {record.path.name} resolves weight {record.weight}, not "
984 f"requested {requested_weight}; install the exact face, choose a "
985 "matching family, or adjust the text operand"
986 )
987
988 italic_axis = axes.get("ital")
989 italic_value = 1.0 if requested_italic else 0.0
990 if italic_axis is not None:
991 if not italic_axis.min_value <= italic_value <= italic_axis.max_value:
992 style = "italic" if requested_italic else "normal"
993 raise ValueError(
994 f"Font {record.path.name} cannot provide requested {style} "
995 "style through its ital axis"
996 )
997 variations["ital"] = italic_value
998 elif record.italic != requested_italic:
999 requested = "italic" if requested_italic else "normal"
1000 resolved = "italic" if record.italic else "normal"
1001 raise ValueError(
1002 f"Font {record.path.name} resolves {resolved}, not requested "
1003 f"{requested}; install the exact face, choose a matching family, "
1004 "or adjust the text operand"
1005 )
1006
1007 if variations:
1008 font.set_variations(variations)
1009
1010
1011 def _draw_functions(hb: Any) -> Any:
1012 functions = hb.DrawFuncs()
1013
1014 def move_to(x: float, y: float, state: _DrawState) -> None:
1015 point = state.point(x, y)
1016 state.commands.append(PathCommand("M", [*point]))
1017
1018 def line_to(x: float, y: float, state: _DrawState) -> None:
1019 point = state.point(x, y)
1020 state.commands.append(PathCommand("L", [*point]))
1021 state.segment_count += 1
1022
1023 def quadratic_to(
1024 control_x: float,
1025 control_y: float,
1026 x: float,
1027 y: float,
1028 state: _DrawState,
1029 ) -> None:
1030 control = state.point(control_x, control_y)
1031 point = state.point(x, y)
1032 state.commands.append(PathCommand(
1033 "Q",
1034 [control[0], control[1], point[0], point[1]],
1035 ))
1036 state.segment_count += 1
1037
1038 def cubic_to(
1039 control_1_x: float,
1040 control_1_y: float,
1041 control_2_x: float,
1042 control_2_y: float,
1043 x: float,
1044 y: float,
1045 state: _DrawState,
1046 ) -> None:
1047 control_1 = state.point(control_1_x, control_1_y)
1048 control_2 = state.point(control_2_x, control_2_y)
1049 point = state.point(x, y)
1050 state.commands.append(PathCommand(
1051 "C",
1052 [
1053 control_1[0],
1054 control_1[1],
1055 control_2[0],
1056 control_2[1],
1057 point[0],
1058 point[1],
1059 ],
1060 ))
1061 state.segment_count += 1
1062
1063 def close_path(state: _DrawState) -> None:
1064 state.commands.append(PathCommand("Z", []))
1065
1066 functions.set_move_to_func(move_to)
1067 functions.set_line_to_func(line_to)
1068 functions.set_quadratic_to_func(quadratic_to)
1069 functions.set_cubic_to_func(cubic_to)
1070 functions.set_close_path_func(close_path)
1071 return functions
1072
1073
1074 def _cluster_offsets(clusters: Sequence[str]) -> list[int]:
1075 offsets: list[int] = []
1076 offset = 0
1077 for cluster in clusters:
1078 offsets.append(offset)
1079 offset += len(cluster)
1080 return offsets
1081
1082
1083 def _required_codepoints(cluster: str) -> frozenset[int]:
1084 return frozenset(
1085 ord(character)
1086 for character in cluster
1087 if not _is_default_ignorable(character)
1088 )
1089
1090
1091 def _is_default_ignorable(character: str) -> bool:
1092 codepoint = ord(character)
1093 return (
1094 unicodedata.category(character) == "Cf"
1095 or 0x180B <= codepoint <= 0x180D
1096 or 0x2061 <= codepoint <= 0x206F
1097 or 0xFE00 <= codepoint <= 0xFE0F
1098 or 0xE0100 <= codepoint <= 0xE01EF
1099 )
1100
1101
1102 def _cluster_may_have_no_outline(cluster: str) -> bool:
1103 return all(
1104 character.isspace() or _is_default_ignorable(character)
1105 for character in cluster
1106 )
1107
1108
1109 def _family_key(value: str) -> str:
1110 return " ".join(value.split()).casefold()
1111
1112
1113 def _local_name(tag: object) -> str:
1114 raw = str(tag)
1115 return raw.rsplit("}", 1)[-1] if "}" in raw else raw
1116
1117
1118 def _element_label(element: ET.Element) -> str:
1119 tag = _local_name(element.tag)
1120 element_id = element.get("id")
1121 return f"<{tag} id={element_id!r}>" if element_id else f"<{tag}>"
1122
1122 lines PYTHON