pyyudit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyyudit/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """pyyudit — cross-platform Python binding to Yudit's Unicode shaping engine.
2
+
3
+ ``pyyudit`` wraps Yudit's standalone shaping engine for complex scripts
4
+ (Indic, Arabic, Thai, Lao, Tibetan, Jamo) into a cross-platform Python wheel
5
+ with per-step trace output.
6
+
7
+ Typical usage::
8
+
9
+ import pyyudit
10
+
11
+ font_bytes = open("NotoSansDevanagari.ttf", "rb").read()
12
+ glyphs = pyyudit.shape(font_bytes, "नमस्ते")
13
+ for g in glyphs:
14
+ print(g.gid, g.cl, g.ax)
15
+
16
+ with pyyudit.YuditFont.from_path("NotoSansDevanagari.ttf") as f:
17
+ traced = f.shape_trace("नमस्ते")
18
+ for stage in traced.stages:
19
+ print(stage.m, len(stage.glyphs))
20
+
21
+ Pure-Python helpers (:func:`upem_from_ttf`, :func:`has_table`) work even
22
+ when no native library is installed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ from ._errors import FontError, LibraryNotFound, ShapingError, YuditError
30
+ from ._font_meta import FontSource, has_table, read_font_bytes, upem_from_ttf
31
+ from ._loader import configure, is_available, library_info, library_path
32
+ from ._types import GlyphRecord, ShapedText, ShapedTrace, TraceStage
33
+ from ._version import __version__, version_tuple
34
+
35
+ if TYPE_CHECKING:
36
+ from ._font import YuditFont
37
+ from ._shaper import shape, shape_trace
38
+
39
+ __all__ = [
40
+ "FontError",
41
+ "FontSource",
42
+ "GlyphRecord",
43
+ "LibraryNotFound",
44
+ "ShapedText",
45
+ "ShapedTrace",
46
+ "ShapingError",
47
+ "TraceStage",
48
+ "YuditError",
49
+ "YuditFont",
50
+ "__version__",
51
+ "configure",
52
+ "has_table",
53
+ "is_available",
54
+ "library_info",
55
+ "library_path",
56
+ "read_font_bytes",
57
+ "upem_from_ttf",
58
+ "version_tuple",
59
+ ]
60
+
61
+ _LIB_HINT = (
62
+ "yudit_shaper native library not available. Build it with CMake "
63
+ "(see README.md) and place the shared library in pyyudit/_lib/ "
64
+ "or set PYYUDIT_LIBRARY_PATH."
65
+ )
66
+
67
+
68
+ if is_available():
69
+ from ._font import YuditFont
70
+ from ._shaper import shape, shape_trace
71
+
72
+ __all__.extend(["shape", "shape_trace"])
73
+ else: # pragma: no cover
74
+
75
+ class _UnavailableFont:
76
+ """Placeholder when the native library is unavailable."""
77
+
78
+ def __init__(self, *a: object, **kw: object) -> None:
79
+ raise LibraryNotFound(_LIB_HINT)
80
+
81
+ def __enter__(self) -> _UnavailableFont:
82
+ return self
83
+
84
+ def __exit__(self, *exc: object) -> None:
85
+ return None
86
+
87
+ @classmethod
88
+ def from_bytes(cls, data: bytes | bytearray = b"") -> _UnavailableFont:
89
+ return cls(data)
90
+
91
+ @classmethod
92
+ def from_path(cls, path: object = ".") -> _UnavailableFont:
93
+ return cls(path)
94
+
95
+ YuditFont = _UnavailableFont # type: ignore[misc, assignment]
96
+
97
+ def shape(font: FontSource, text: str, **kwargs: object) -> list[GlyphRecord]: # type: ignore[no-redef]
98
+ raise LibraryNotFound(_LIB_HINT)
99
+
100
+ def shape_trace(font: FontSource, text: str, **kwargs: object) -> ShapedTrace: # type: ignore[no-redef]
101
+ raise LibraryNotFound(_LIB_HINT)
pyyudit/_binding.py ADDED
@@ -0,0 +1,90 @@
1
+ """Low-level ctypes binding to the yudit_shaper native library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from ctypes import (
7
+ POINTER,
8
+ Structure,
9
+ c_char_p,
10
+ c_int,
11
+ c_int32,
12
+ c_size_t,
13
+ c_uint8,
14
+ c_uint32,
15
+ c_void_p,
16
+ )
17
+ from typing import Any, Callable
18
+
19
+ from ._loader import load
20
+
21
+ _loaded = load()
22
+ if _loaded is None:
23
+ raise ImportError("yudit_shaper native library not found. See pyyudit._loader")
24
+
25
+ _lib: ctypes.CDLL = _loaded
26
+
27
+
28
+ def _fn(name: str, res: Any, *params: Any, **kwds: Any) -> None:
29
+ """Declare the restype/argtypes for a native function."""
30
+ f = getattr(_lib, name)
31
+ f.restype = res
32
+ f.argtypes = params
33
+ errcheck = kwds.get("errcheck")
34
+ if errcheck:
35
+ f.errcheck = errcheck
36
+
37
+
38
+ def _check(err: int, func: Callable[..., Any], _args: tuple[Any, ...]) -> int:
39
+ """Raise on negative error codes."""
40
+ if err < 0:
41
+ raise RuntimeError(f"{func.__name__} failed: error code {err}")
42
+ return err
43
+
44
+
45
+ # ── Structures ─────────────────────────────────────────────────────────────
46
+
47
+
48
+ class _Glyph(Structure):
49
+ _fields_ = [
50
+ ("glyph_id", c_uint32),
51
+ ("codepoint", c_uint32),
52
+ ("x", c_int32),
53
+ ("y", c_int32),
54
+ ("width", c_int32),
55
+ ("cluster", c_int32),
56
+ ]
57
+
58
+
59
+ class _TraceStage(Structure):
60
+ _fields_ = [
61
+ ("message", c_char_p),
62
+ ("glyphs", POINTER(_Glyph)),
63
+ ("glyph_count", c_uint32),
64
+ ("depth", c_int),
65
+ ("effective", c_int),
66
+ ]
67
+
68
+
69
+ # ── Function declarations ─────────────────────────────────────────────────
70
+
71
+ _fn("yudit_version", c_char_p)
72
+ _fn("yudit_error_string", c_char_p, c_int)
73
+
74
+ # Font
75
+ _fn("yudit_font_open", c_int, POINTER(c_uint8), c_size_t, c_int, POINTER(c_void_p), errcheck=_check)
76
+ _fn("yudit_font_close", None, c_void_p)
77
+ _fn("yudit_font_upem", c_int, c_void_p)
78
+ _fn("yudit_font_glyph_count", c_int, c_void_p)
79
+ _fn("yudit_font_name", c_char_p, c_void_p)
80
+
81
+ # Shaping
82
+ _fn("yudit_shape", c_int, c_void_p, POINTER(c_uint32), c_uint32, POINTER(c_void_p), errcheck=_check)
83
+ _fn("yudit_result_free", None, c_void_p)
84
+
85
+ # Result queries
86
+ _fn("yudit_result_glyph_count", c_uint32, c_void_p)
87
+ _fn("yudit_result_glyphs", POINTER(_Glyph), c_void_p)
88
+ _fn("yudit_result_width", c_int, c_void_p)
89
+ _fn("yudit_result_trace_count", c_uint32, c_void_p)
90
+ _fn("yudit_result_trace_stage", POINTER(_TraceStage), c_void_p, c_uint32)
pyyudit/_errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Exception hierarchy for pyyudit."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class YuditError(Exception):
7
+ """Base exception for all pyyudit errors."""
8
+
9
+
10
+ class LibraryNotFound(YuditError):
11
+ """The native yudit_shaper library could not be found or loaded."""
12
+
13
+
14
+ class FontError(YuditError):
15
+ """The font data is invalid or unsupported."""
16
+
17
+
18
+ class ShapingError(YuditError):
19
+ """Shaping failed (unsupported script, empty text, etc.)."""
pyyudit/_font.py ADDED
@@ -0,0 +1,185 @@
1
+ """YuditFont — a loaded font ready for shaping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ._binding import _lib
10
+ from ._errors import FontError, LibraryNotFound
11
+ from ._font_meta import read_font_bytes
12
+ from ._types import GlyphRecord, ShapedText, ShapedTrace, TraceStage
13
+
14
+ __all__ = ["YuditFont"]
15
+
16
+
17
+ class YuditFont:
18
+ """A TrueType/OpenType font loaded by Yudit's shaping engine.
19
+
20
+ Usage::
21
+
22
+ font = YuditFont.from_path("NotoSansDevanagari.ttf")
23
+ result = font.shape_trace("नमस्ते")
24
+ for stage in result.stages:
25
+ print(stage.m, len(stage.glyphs))
26
+ """
27
+
28
+ def __init__(self, data: bytes, *, face_index: int = 0) -> None:
29
+ if not _lib:
30
+ raise LibraryNotFound("yudit_shaper native library not available.")
31
+
32
+ self._handle = ctypes.c_void_p()
33
+ buf = (ctypes.c_uint8 * len(data))(*data)
34
+ try:
35
+ _lib.yudit_font_open(buf, len(data), face_index, ctypes.byref(self._handle))
36
+ except RuntimeError as exc:
37
+ raise FontError(str(exc)) from exc
38
+
39
+ if not self._handle:
40
+ raise FontError("yudit_font_open returned NULL")
41
+
42
+ self._data = data # prevent GC of the buffer
43
+
44
+ @classmethod
45
+ def from_path(cls, path: str | Path) -> YuditFont:
46
+ """Load a font from a file path."""
47
+ return cls(read_font_bytes(path))
48
+
49
+ @classmethod
50
+ def from_bytes(cls, data: bytes) -> YuditFont:
51
+ """Load a font from raw bytes."""
52
+ return cls(data)
53
+
54
+ def close(self) -> None:
55
+ """Release the native font handle."""
56
+ if self._handle:
57
+ _lib.yudit_font_close(self._handle)
58
+ self._handle = ctypes.c_void_p()
59
+
60
+ def __enter__(self) -> YuditFont:
61
+ return self
62
+
63
+ def __exit__(self, *exc: object) -> None:
64
+ self.close()
65
+
66
+ @property
67
+ def upem(self) -> int:
68
+ """Units per em."""
69
+ return _lib.yudit_font_upem(self._handle)
70
+
71
+ @property
72
+ def glyph_count(self) -> int:
73
+ """Number of glyphs in the font."""
74
+ return _lib.yudit_font_glyph_count(self._handle)
75
+
76
+ @property
77
+ def name(self) -> str:
78
+ """Font name."""
79
+ n = _lib.yudit_font_name(self._handle)
80
+ return n.decode("utf-8", errors="replace") if n else ""
81
+
82
+ # ── Shaping ────────────────────────────────────────────────────────────
83
+
84
+ def shape(self, text: str) -> ShapedText:
85
+ """Shape text and return positioned glyphs (no trace)."""
86
+ result = self.shape_trace(text)
87
+ return ShapedText(
88
+ glyphs=result.final_glyphs,
89
+ advance_x=result.advance_x,
90
+ advance_y=result.advance_y,
91
+ text=text,
92
+ engine="yudit",
93
+ )
94
+
95
+ def shape_trace(self, text: str) -> ShapedTrace:
96
+ """Shape text and return positioned glyphs with per-step trace."""
97
+ if not text:
98
+ from ._types import ShapedTrace
99
+
100
+ return ShapedTrace(
101
+ stages=(),
102
+ final_glyphs=(),
103
+ advance_x=0,
104
+ advance_y=0,
105
+ text=text,
106
+ engine="yudit",
107
+ )
108
+
109
+ # Encode text as UTF-32
110
+ encoded = text.encode("utf-32-le")
111
+ n_codepoints = len(encoded) // 4
112
+ # Convert bytes to c_uint32 array via cast
113
+ import array
114
+
115
+ arr = array.array("I", encoded)
116
+ buf = (ctypes.c_uint32 * n_codepoints)(*arr)
117
+
118
+ result_handle = ctypes.c_void_p()
119
+ try:
120
+ _lib.yudit_shape(self._handle, buf, n_codepoints, ctypes.byref(result_handle))
121
+ except RuntimeError as exc:
122
+ from ._errors import ShapingError
123
+
124
+ raise ShapingError(str(exc)) from exc
125
+
126
+ try:
127
+ return self._extract_result(result_handle, text)
128
+ finally:
129
+ _lib.yudit_result_free(result_handle)
130
+
131
+ def _extract_result(self, handle: ctypes.c_void_p, text: str) -> ShapedTrace:
132
+ """Extract glyphs and trace from a native result handle."""
133
+ # Final glyphs
134
+ glyph_count = _lib.yudit_result_glyph_count(handle)
135
+ glyphs_ptr = _lib.yudit_result_glyphs(handle)
136
+ final_glyphs = self._parse_glyphs(glyphs_ptr, glyph_count)
137
+
138
+ # Trace stages
139
+ trace_count = _lib.yudit_result_trace_count(handle)
140
+ stages: list[TraceStage] = []
141
+ for i in range(trace_count):
142
+ stage_ptr = _lib.yudit_result_trace_stage(handle, i)
143
+ if not stage_ptr:
144
+ continue
145
+ stage = stage_ptr.contents
146
+ msg = stage.message.decode("utf-8", errors="replace") if stage.message else ""
147
+ stage_glyphs = self._parse_glyphs(stage.glyphs, stage.glyph_count)
148
+ stages.append(
149
+ TraceStage(
150
+ m=msg,
151
+ glyphs=tuple(stage_glyphs),
152
+ depth=stage.depth,
153
+ effective=bool(stage.effective),
154
+ )
155
+ )
156
+
157
+ advance_x = _lib.yudit_result_width(handle)
158
+
159
+ return ShapedTrace(
160
+ stages=tuple(stages),
161
+ final_glyphs=tuple(final_glyphs),
162
+ advance_x=advance_x,
163
+ advance_y=0,
164
+ text=text,
165
+ engine="yudit",
166
+ )
167
+
168
+ @staticmethod
169
+ def _parse_glyphs(ptr: Any, count: int) -> list[GlyphRecord]:
170
+ """Parse a native glyph array into Python GlyphRecords."""
171
+ glyphs: list[GlyphRecord] = []
172
+ for i in range(count):
173
+ g = ptr[i]
174
+ glyphs.append(
175
+ GlyphRecord(
176
+ g=g.glyph_id,
177
+ cl=g.cluster,
178
+ dx=g.x,
179
+ dy=g.y,
180
+ ax=g.width,
181
+ ay=0,
182
+ flags=0,
183
+ )
184
+ )
185
+ return glyphs
pyyudit/_font_meta.py ADDED
@@ -0,0 +1,80 @@
1
+ """Pure-Python font metadata helpers (no native library required)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ __all__ = ["FontSource", "has_table", "read_font_bytes", "upem_from_ttf"]
10
+
11
+
12
+ FontSource = Union[bytes, bytearray, str, Path]
13
+
14
+
15
+ def read_font_bytes(source: FontSource) -> bytes:
16
+ """Read font data from bytes or a file path."""
17
+ if isinstance(source, (bytes, bytearray)):
18
+ return bytes(source)
19
+ path = Path(source)
20
+ if not path.is_file():
21
+ raise FileNotFoundError(f"Font file not found: {path}")
22
+ return path.read_bytes()
23
+
24
+
25
+ def _read_u16(data: bytes, offset: int) -> int:
26
+ return struct.unpack_from(">H", data, offset)[0]
27
+
28
+
29
+ def _read_u32(data: bytes, offset: int) -> int:
30
+ return struct.unpack_from(">I", data, offset)[0]
31
+
32
+
33
+ def upem_from_ttf(font_bytes: bytes) -> int:
34
+ """Read units-per-em from the head table of a TrueType/OpenType font."""
35
+ try:
36
+ num_tables = _read_u16(font_bytes, 4)
37
+ for i in range(num_tables):
38
+ offset = 12 + i * 16
39
+ tag = font_bytes[offset : offset + 4]
40
+ table_offset = _read_u32(font_bytes, offset + 8)
41
+ if tag == b"head" and table_offset + 18 + 2 <= len(font_bytes):
42
+ return _read_u16(font_bytes, table_offset + 18)
43
+ except (struct.error, IndexError):
44
+ pass
45
+ return 1000 # default
46
+
47
+
48
+ def has_table(font_bytes: bytes, tag: str) -> bool:
49
+ """Check if a font contains a specific OpenType table."""
50
+ tag_bytes = tag.encode("ascii")[:4]
51
+ try:
52
+ num_tables = _read_u16(font_bytes, 4)
53
+ for i in range(num_tables):
54
+ offset = 12 + i * 16
55
+ t = font_bytes[offset : offset + 4]
56
+ if t == tag_bytes:
57
+ return True
58
+ except (struct.error, IndexError):
59
+ pass
60
+ return False
61
+
62
+
63
+ def is_graphite_font(font_bytes: bytes) -> bool:
64
+ """Check if a font has Graphite tables (silf/sill/glat/gloc)."""
65
+ return any(has_table(font_bytes, t) for t in ("silf", "sill"))
66
+
67
+
68
+ def glyph_count_from_ttf(font_bytes: bytes) -> int:
69
+ """Read the number of glyphs from the maxp table."""
70
+ try:
71
+ num_tables = _read_u16(font_bytes, 4)
72
+ for i in range(num_tables):
73
+ offset = 12 + i * 16
74
+ tag = font_bytes[offset : offset + 4]
75
+ table_offset = _read_u32(font_bytes, offset + 8)
76
+ if tag == b"maxp" and table_offset + 6 <= len(font_bytes):
77
+ return _read_u16(font_bytes, table_offset + 4)
78
+ except (struct.error, IndexError):
79
+ pass
80
+ return 0
Binary file
pyyudit/_loader.py ADDED
@@ -0,0 +1,141 @@
1
+ """Cross-platform discovery and loading of the yudit_shaper native library.
2
+
3
+ Search order (first hit wins):
4
+
5
+ 1. An explicit path passed to :func:`configure`.
6
+ 2. The ``PYYUDIT_LIBRARY_PATH`` environment variable.
7
+ 3. A library bundled inside the wheel at ``pyyudit/_lib/``.
8
+ 4. A system-installed library (``libyudit_shaper.so`` / ``yudit_shaper.dll``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ctypes
14
+ import ctypes.util
15
+ import os
16
+ import sys
17
+ from collections.abc import Iterator
18
+ from pathlib import Path
19
+
20
+ __all__ = [
21
+ "configure",
22
+ "is_available",
23
+ "library_info",
24
+ "library_path",
25
+ "load",
26
+ ]
27
+
28
+ if os.name == "nt":
29
+ _PLATFORM_NAMES: tuple[str, ...] = ("yudit_shaper.dll",)
30
+ elif sys.platform == "darwin":
31
+ _PLATFORM_NAMES = ("libyudit_shaper.dylib",)
32
+ else:
33
+ _PLATFORM_NAMES = ("libyudit_shaper.so", "libyudit_shaper.so.0")
34
+
35
+ _PACKAGE_DIR = Path(__file__).resolve().parent
36
+ _LIB_DIR = _PACKAGE_DIR / "_lib"
37
+
38
+ # Explicit override set via configure(); None means "auto-discover".
39
+ _configured_path: Path | None = None
40
+ _resolved_path: Path | None = None
41
+ _cached_library: ctypes.CDLL | None = None
42
+
43
+
44
+ def _platform_candidates(directory: Path) -> Iterator[Path]:
45
+ for name in _PLATFORM_NAMES:
46
+ yield directory / name
47
+
48
+
49
+ def _candidate_paths() -> Iterator[Path]:
50
+ seen: set[Path] = set()
51
+
52
+ def _offer(path: Path) -> Iterator[Path]:
53
+ if path not in seen:
54
+ seen.add(path)
55
+ yield path
56
+
57
+ # 1. explicit runtime configuration
58
+ if _configured_path is not None:
59
+ if _configured_path.is_dir():
60
+ for p in _platform_candidates(_configured_path):
61
+ yield from _offer(p)
62
+ else:
63
+ yield from _offer(_configured_path)
64
+
65
+ # 2. environment variable
66
+ env = os.environ.get("PYYUDIT_LIBRARY_PATH")
67
+ if env:
68
+ env_path = Path(env)
69
+ if env_path.is_dir():
70
+ for p in _platform_candidates(env_path):
71
+ yield from _offer(p)
72
+ else:
73
+ yield from _offer(env_path)
74
+
75
+ # 3. wheel-local _lib/ directory
76
+ if _LIB_DIR.is_dir():
77
+ for p in _platform_candidates(_LIB_DIR):
78
+ yield from _offer(p)
79
+
80
+ # 4. system library
81
+ for name in _PLATFORM_NAMES:
82
+ found = ctypes.util.find_library(name.rsplit(".", 1)[0])
83
+ if found:
84
+ yield from _offer(Path(found))
85
+
86
+
87
+ def resolve() -> Path | None:
88
+ global _resolved_path
89
+ if _resolved_path is not None:
90
+ return _resolved_path
91
+ for path in _candidate_paths():
92
+ if path.is_file():
93
+ _resolved_path = path
94
+ return path
95
+ return None
96
+
97
+
98
+ def load() -> ctypes.CDLL | None:
99
+ global _cached_library
100
+ if _cached_library is not None:
101
+ return _cached_library
102
+ path = resolve()
103
+ if path is None:
104
+ return None
105
+ try:
106
+ if os.name == "nt" and path.is_file():
107
+ os.add_dll_directory(str(path.parent))
108
+ _cached_library = ctypes.CDLL(str(path))
109
+ return _cached_library
110
+ except OSError:
111
+ return None
112
+
113
+
114
+ def configure(path: str | Path | None = None) -> None:
115
+ global _configured_path, _resolved_path, _cached_library
116
+ _configured_path = Path(path) if path else None
117
+ _resolved_path = None
118
+ _cached_library = None
119
+
120
+
121
+ def is_available() -> bool:
122
+ return load() is not None
123
+
124
+
125
+ def library_path() -> Path | None:
126
+ return resolve()
127
+
128
+
129
+ def library_info() -> dict[str, object]:
130
+ path = resolve()
131
+ lib = load()
132
+ return {
133
+ "available": lib is not None,
134
+ "path": str(path) if path else None,
135
+ "search_order": [
136
+ "configure()",
137
+ "PYYUDIT_LIBRARY_PATH",
138
+ "pyyudit/_lib/",
139
+ "system library",
140
+ ],
141
+ }
pyyudit/_shaper.py ADDED
@@ -0,0 +1,51 @@
1
+ """One-shot shaping functions (the main user-facing API)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._font_meta import FontSource, read_font_bytes
6
+ from ._types import GlyphRecord, ShapedTrace
7
+
8
+ __all__ = ["shape", "shape_trace"]
9
+
10
+ _LIB_HINT = (
11
+ "yudit_shaper native library not available. Build it with CMake "
12
+ "(see README.md) and place the shared library in pyyudit/_lib/ "
13
+ "or set PYYUDIT_LIBRARY_PATH."
14
+ )
15
+
16
+
17
+ def shape(
18
+ font: FontSource,
19
+ text: str,
20
+ **kwargs: object,
21
+ ) -> list[GlyphRecord]:
22
+ """Shape text with a font and return positioned glyphs.
23
+
24
+ This is a convenience wrapper around :class:`YuditFont`.
25
+
26
+ :param font: font bytes or file path.
27
+ :param text: Unicode text to shape.
28
+ :returns: list of :class:`GlyphRecord` with positioning.
29
+ """
30
+ from ._font import YuditFont
31
+
32
+ with YuditFont.from_bytes(read_font_bytes(font)) as f:
33
+ result = f.shape(text)
34
+ return list(result.glyphs)
35
+
36
+
37
+ def shape_trace(
38
+ font: FontSource,
39
+ text: str,
40
+ **kwargs: object,
41
+ ) -> ShapedTrace:
42
+ """Shape text with a font and return the full trace.
43
+
44
+ :param font: font bytes or file path.
45
+ :param text: Unicode text to shape.
46
+ :returns: :class:`ShapedTrace` with per-step stages.
47
+ """
48
+ from ._font import YuditFont
49
+
50
+ with YuditFont.from_bytes(read_font_bytes(font)) as f:
51
+ return f.shape_trace(text)