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 +101 -0
- pyyudit/_binding.py +90 -0
- pyyudit/_errors.py +19 -0
- pyyudit/_font.py +185 -0
- pyyudit/_font_meta.py +80 -0
- pyyudit/_lib/yudit_shaper.so +0 -0
- pyyudit/_loader.py +141 -0
- pyyudit/_shaper.py +51 -0
- pyyudit/_types.py +51 -0
- pyyudit/_version.py +6 -0
- pyyudit/py.typed +0 -0
- pyyudit/pyyudit.pyi +76 -0
- pyyudit-0.1.0.dist-info/METADATA +196 -0
- pyyudit-0.1.0.dist-info/RECORD +16 -0
- pyyudit-0.1.0.dist-info/WHEEL +4 -0
- pyyudit-0.1.0.dist-info/licenses/LICENSE +338 -0
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)
|