From bebc63491e2f8812b53c58e370b722844e0be4b2 Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Wed, 29 Apr 2026 22:04:20 -0700 Subject: [PATCH 1/2] Add PEP 561 type stubs and expand README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship `__init__.pyi` covering every public type and function with property signatures, exception notes, and `Sequence[Feature] | str | None` unions that match the runtime accepted shapes. Add an empty `py.typed` marker so type checkers pick up the stubs from an installed wheel — maturin already includes both files from `python-source` without further config. Suppress pyright on the five negative-type test lines that intentionally pass wrong types to verify TypeError. Rewrite the README around installation, the two-tier API (high-level `shape()` and the `Font` / `Buffer` / `GlyphBuffer` object API), variable fonts, buffer recycling, and the error taxonomy. --- README.md | 122 ++++++++++++++ python/pyharfrust/__init__.pyi | 290 +++++++++++++++++++++++++++++++++ python/pyharfrust/py.typed | 0 tests/test_buffer.py | 6 +- tests/test_font.py | 4 +- 5 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 python/pyharfrust/__init__.pyi create mode 100644 python/pyharfrust/py.typed diff --git a/README.md b/README.md index b6f5d90..4b67ad7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,128 @@ HarfBuzz is the industry-standard text shaping engine used by Firefox, Chrome, A - **Pythonic interface** — string-based construction for configuration types (`Direction("rtl")`, `Feature("+kern")`, `Variation("wght=700")`), iteration over glyph results, and clear error messages. - **HarfBuzz test compatibility** — ability to run against HarfBuzz's `.tests` regression format, enabling direct comparison between the two engines. +## Installation + +Wheels are not yet published. To build from source you need a Rust toolchain (>= 1.85) and Python (>= 3.11): + +```bash +git clone https://github.com/hasanzakeri/harfrust-python.git +cd harfrust-python +pip install maturin +maturin develop --release +``` + +The package is imported as `pyharfrust`: + +```python +import pyharfrust +print(pyharfrust.__version__) +``` + +PEP 561 type stubs (`__init__.pyi`, `py.typed`) ship with the package, so editors and type checkers see the full API. + +## Quick start + +Two ways to shape a string. Both produce the same output. + +### High-level `shape()` function + +```python +from pyharfrust import shape + +result = shape("path/to/font.ttf", "Hello World", "") +# "[H=0+733|e=1+598|l=2+336|l=3+336|o=4+631|space=5+272|W=6+871|o=7+631|r=8+380|l=9+336|d=10+629]" +``` + +The third argument accepts the same flags as the `hb-shape` CLI: + +```python +shape("font.ttf", "AB", "--features=+kern,-liga --direction=ltr") +``` + +### Object API + +For repeated shaping, font configuration, or access to per-glyph metadata, use the object API: + +```python +from pyharfrust import Buffer, Feature, Font + +font = Font("path/to/font.ttf") + +buf = Buffer() +buf.add_str("Hello World") +buf.guess_segment_properties() # infers direction/script/language + +glyphs = font.shape(buf, features=[Feature("+kern")]) + +for info, pos in glyphs: + print(f"glyph={info.glyph_id} cluster={info.cluster} " + f"advance=({pos.x_advance},{pos.y_advance}) " + f"offset=({pos.x_offset},{pos.y_offset})") +``` + +The `serialize()` method produces the same string format as the high-level `shape()` function: + +```python +print(glyphs.serialize(font)) +``` + +## Configuration types + +All configuration types accept either a string or their structured form. Strings parse with the same syntax as `hb-shape`: + +```python +from pyharfrust import Direction, Feature, Language, Script, Variation + +Direction("ltr") # or Direction.LTR +Script("Latn") # 4-letter ISO 15924 tag +Language("en-US") +Feature("+kern") # enable; "-liga" disables; "kern[3:5]=2" applies a range +Variation("wght=700") # variable-font axis setting +``` + +## Variable fonts + +```python +from pyharfrust import Font, Variation + +font = Font("variable.ttf") +font.set_variations([Variation("wght=700"), Variation("wdth=85")]) +# or +font.set_variations("wght=700,wdth=85") + +# Reset to defaults: +font.set_variations([]) +``` + +## Buffer recycling + +Buffers are consumed by `shape()`. Recycle them via `GlyphBuffer.clear()`: + +```python +from pyharfrust import Buffer, Font + +font = Font("font.ttf") +buf = Buffer() +buf.add_str("First") +buf.guess_segment_properties() +glyphs = font.shape(buf) + +# Reuse the same allocation for a new shaping call: +buf = glyphs.clear() +buf.add_str("Second") +buf.guess_segment_properties() +glyphs = font.shape(buf) +``` + +Reusing a consumed `Buffer` (or `GlyphBuffer`) raises `ValueError`. + +## Errors + +- `RuntimeError` — font cannot be loaded or parsed. +- `ValueError` — invalid string input (`Direction("xyz")`, `Feature("=")`), unset buffer direction at shape time, or use of an already-consumed buffer. +- `TypeError` — wrong argument types (e.g. assigning a string to `Buffer.direction`). + ## Technical Approach - **PyO3 + maturin** — the standard modern toolchain for building Rust extensions for Python. diff --git a/python/pyharfrust/__init__.pyi b/python/pyharfrust/__init__.pyi new file mode 100644 index 0000000..a20bebc --- /dev/null +++ b/python/pyharfrust/__init__.pyi @@ -0,0 +1,290 @@ +from collections.abc import Iterator, Sequence +from typing import ClassVar, final + +__version__: str + +# --------------------------------------------------------------------------- +# Value types +# --------------------------------------------------------------------------- + +@final +class Direction: + """Text direction: left-to-right, right-to-left, top-to-bottom, bottom-to-top.""" + + LTR: ClassVar[Direction] + RTL: ClassVar[Direction] + TTB: ClassVar[Direction] + BTT: ClassVar[Direction] + + def __init__(self, s: str) -> None: + """Parse a direction string ("ltr", "rtl", "ttb", "btt"; case-insensitive). + + Raises ``ValueError`` on an unknown string. + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Script: + """An ISO 15924 script tag (e.g. "Latn", "Arab", "Deva").""" + + def __init__(self, s: str) -> None: + """Construct from a 4-letter ISO 15924 tag. + + Raises ``ValueError`` if the input is not 4 ASCII letters. + """ + + @property + def tag(self) -> str: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Language: + """A BCP 47 language tag (e.g. "en", "ar", "en-US").""" + + def __init__(self, s: str) -> None: + """Parse a BCP 47 language tag. Raises ``ValueError`` on empty input.""" + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Feature: + """An OpenType feature setting (e.g. "+kern", "-liga", "kern[3:5]=2").""" + + def __init__(self, s: str) -> None: + """Parse a feature string. Raises ``ValueError`` on invalid syntax.""" + + @property + def tag(self) -> str: ... + @property + def value(self) -> int: ... + @property + def start(self) -> int: ... + @property + def end(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Variation: + """A font variation axis setting (e.g. "wght=700", "wdth=85.5"). + + Not hashable: variation values are floats. + """ + + def __init__(self, s: str) -> None: + """Parse a variation string. Raises ``ValueError`` on invalid syntax.""" + + @property + def tag(self) -> str: ... + @property + def value(self) -> float: ... + def __eq__(self, other: object) -> bool: ... + __hash__: ClassVar[None] # type: ignore[assignment] + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +# --------------------------------------------------------------------------- +# Buffer +# --------------------------------------------------------------------------- + +@final +class Buffer: + """Mutable text buffer used as input to shaping. + + A ``Buffer`` is consumed when passed to :meth:`Font.shape` — any further + use raises ``ValueError``. Reuse a buffer by recycling it through + :meth:`GlyphBuffer.clear`. + """ + + def __init__(self) -> None: ... + def __len__(self) -> int: ... + def __repr__(self) -> str: ... + def add_str(self, s: str) -> None: + """Append a Unicode string to the buffer.""" + + def add(self, codepoint: int, cluster: int = 0) -> None: + """Append a single Unicode codepoint with an explicit cluster value.""" + + def clear(self) -> None: + """Drop all codepoints and reset segment properties.""" + + def reset_clusters(self) -> None: + """Reset cluster values to be sequential (0, 1, 2, ...).""" + + def guess_segment_properties(self) -> None: + """Guess direction, script, and language from the buffer contents.""" + + def reserve(self, size: int) -> bool: + """Reserve capacity for at least ``size`` items.""" + + def set_pre_context(self, s: str) -> None: + """Set context preceding the buffer for shaping decisions.""" + + def set_post_context(self, s: str) -> None: + """Set context following the buffer for shaping decisions.""" + + def set_not_found_variation_selector_glyph(self, glyph: int) -> None: + """Set the glyph emitted for a variation selector with no match.""" + + @property + def direction(self) -> Direction: ... + @direction.setter + def direction(self, value: Direction) -> None: ... + @property + def script(self) -> Script: ... + @script.setter + def script(self, value: Script) -> None: ... + @property + def language(self) -> Language | None: ... + @language.setter + def language(self, value: Language) -> None: ... + +# --------------------------------------------------------------------------- +# Glyphs +# --------------------------------------------------------------------------- + +@final +class GlyphInfo: + """Read-only information about a shaped glyph. + + Snapshot value type: properties reflect the underlying glyph at the time + the info was retrieved from a :class:`GlyphBuffer`. + """ + + @property + def glyph_id(self) -> int: ... + @property + def cluster(self) -> int: ... + @property + def unsafe_to_break(self) -> bool: ... + @property + def unsafe_to_concat(self) -> bool: ... + @property + def safe_to_insert_tatweel(self) -> bool: ... + def __repr__(self) -> str: ... + +@final +class GlyphPosition: + """Read-only positioning information for a shaped glyph (font units).""" + + @property + def x_advance(self) -> int: ... + @property + def y_advance(self) -> int: ... + @property + def x_offset(self) -> int: ... + @property + def y_offset(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class GlyphBuffer: + """Output of :meth:`Font.shape`: a sequence of glyphs and their positions. + + Iterating yields ``(GlyphInfo, GlyphPosition)`` pairs. The buffer is + consumed by :meth:`clear`, which returns a recycled :class:`Buffer`. + """ + + def __len__(self) -> int: ... + def __getitem__(self, index: int) -> tuple[GlyphInfo, GlyphPosition]: ... + def __iter__(self) -> Iterator[tuple[GlyphInfo, GlyphPosition]]: ... + def __repr__(self) -> str: ... + @property + def glyph_infos(self) -> list[GlyphInfo]: ... + @property + def glyph_positions(self) -> list[GlyphPosition]: ... + def clear(self) -> Buffer: + """Consume this glyph buffer and return its underlying buffer for reuse.""" + + def serialize(self, font: Font) -> str: + """Format the shaped glyphs as a string matching ``shape()`` output.""" + +# --------------------------------------------------------------------------- +# Font +# --------------------------------------------------------------------------- + +@final +class Font: + """A loaded font face used for shaping. + + Owns the font bytes and shaping data. Variations and point size can be + adjusted between shape calls. + """ + + def __init__(self, path: str, face_index: int = 0) -> None: + """Load a font from the filesystem. + + Raises ``RuntimeError`` if the file cannot be read or parsed. + """ + + @staticmethod + def from_bytes(data: bytes, face_index: int = 0) -> Font: + """Load a font from in-memory bytes. + + Raises ``RuntimeError`` if the data cannot be parsed as a font. + """ + + @property + def face_index(self) -> int: ... + @property + def units_per_em(self) -> int: ... + def set_variations(self, variations: Sequence[Variation] | str) -> None: + """Set the active variation axes. An empty sequence resets to defaults. + + Raises ``ValueError`` if a string contains an unparseable variation, + or ``TypeError`` if the argument is neither a string nor a sequence + of :class:`Variation` objects. + """ + + def set_point_size(self, size: float | None) -> None: + """Set the active point size, or clear it with ``None``.""" + + def shape( + self, + buffer: Buffer, + features: Sequence[Feature] | str | None = None, + ) -> GlyphBuffer: + """Shape the given buffer. + + Consumes the buffer; subsequent use of the same buffer raises + ``ValueError``. Direction must be set on the buffer (typically via + :meth:`Buffer.guess_segment_properties`) — otherwise raises + ``ValueError``. + + Raises ``ValueError`` on invalid feature syntax, or ``TypeError`` if + ``features`` is not a string, sequence of :class:`Feature`, or ``None``. + """ + + def __repr__(self) -> str: ... + +# --------------------------------------------------------------------------- +# High-level functions +# --------------------------------------------------------------------------- + +def shape(font_path: str, text: str, options: str = "") -> str: + """Shape ``text`` with the font at ``font_path`` and return the serialized output. + + ``options`` accepts the same flags as the ``hb-shape`` CLI (e.g. + ``"--direction=rtl"``, ``"--features=+kern,-liga"``). + + Raises ``RuntimeError`` if the font cannot be loaded or the options are + invalid. + """ + +def run_from_args(args: Sequence[str]) -> str: + """Run the equivalent of the ``hb-shape`` CLI with ``args``. + + Raises ``RuntimeError`` if argument parsing or shaping fails. + """ diff --git a/python/pyharfrust/py.typed b/python/pyharfrust/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_buffer.py b/tests/test_buffer.py index 656596b..c8a3a2b 100644 --- a/tests/test_buffer.py +++ b/tests/test_buffer.py @@ -106,17 +106,17 @@ def test_language_none_when_unset(self): def test_direction_setter_rejects_nondirection(self): buf = Buffer() with pytest.raises(TypeError): - buf.direction = "ltr" + buf.direction = "ltr" # pyright: ignore[reportAttributeAccessIssue] def test_script_setter_rejects_nonscript(self): buf = Buffer() with pytest.raises(TypeError): - buf.script = "Latn" + buf.script = "Latn" # pyright: ignore[reportAttributeAccessIssue] def test_language_setter_rejects_nonlanguage(self): buf = Buffer() with pytest.raises(TypeError): - buf.language = "en" + buf.language = "en" # pyright: ignore[reportAttributeAccessIssue] # --------------------------------------------------------------------------- diff --git a/tests/test_font.py b/tests/test_font.py index f0b42fa..a57cf99 100644 --- a/tests/test_font.py +++ b/tests/test_font.py @@ -258,7 +258,7 @@ def test_features_wrong_type(self): buf = Buffer() buf.add_str("AB") with pytest.raises(TypeError): - font.shape(buf, features=42) + font.shape(buf, features=42) # pyright: ignore[reportArgumentType] # --------------------------------------------------------------------------- @@ -302,7 +302,7 @@ def test_set_variations_invalid(self): def test_set_variations_wrong_type(self): font = Font(OPEN_SANS) with pytest.raises(TypeError): - font.set_variations(42) + font.set_variations(42) # pyright: ignore[reportArgumentType] # --------------------------------------------------------------------------- From 8f2f7ce4bcee3d74dcc28f1657eccc35bf095bd2 Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Thu, 30 Apr 2026 00:11:09 -0700 Subject: [PATCH 2/2] Update README.md to enhance CLI usage documentation and clarify GlyphBuffer behavior. Added example for `run_from_args(argv)` to demonstrate full CLI parity with `hb-shape`. Updated description of `GlyphBuffer.clear()` to specify its consumption behavior and error handling. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b67ad7..d75d5e9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ The third argument accepts the same flags as the `hb-shape` CLI: shape("font.ttf", "AB", "--features=+kern,-liga --direction=ltr") ``` +For full CLI parity, `run_from_args(argv)` accepts the same argument list as `hb-shape` — `argv[0]` is the program name, the rest are flags: + +```python +from pyharfrust import run_from_args +run_from_args(["hb-shape", "--font-file=font.ttf", "--features=+kern", "Hello"]) +``` + ### Object API For repeated shaping, font configuration, or access to per-glyph metadata, use the object API: @@ -129,7 +136,7 @@ buf.guess_segment_properties() glyphs = font.shape(buf) ``` -Reusing a consumed `Buffer` (or `GlyphBuffer`) raises `ValueError`. +`GlyphBuffer.clear()` consumes the glyph buffer: any further access to the original `glyphs` instance — including a second `clear()` — raises `ValueError`. The same applies to a `Buffer` once it has been passed to `shape()`. ## Errors