From 3d721e12131829ffe233e82eb411479e83e84f1e Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Sat, 25 Apr 2026 14:47:52 -0700 Subject: [PATCH 1/2] feat: Introduce Font, GlyphBuffer, GlyphInfo, and GlyphPosition classes - Added `Font` class for loading and managing font data, including methods for creating instances from file paths and byte arrays. - Introduced `GlyphBuffer`, `GlyphInfo`, and `GlyphPosition` classes to handle glyph shaping and positioning, providing a structured way to access glyph information. - Updated `__init__.py` to include new classes in the module's public API. - Enhanced `Buffer` class with methods for handling glyph data and serialization. - Added comprehensive tests for the new font and glyph functionalities, ensuring correct behavior and integration with existing components. --- python/pyharfrust/__init__.py | 8 + src/buffer.rs | 2 +- src/font.rs | 178 +++++++++++++++++ src/glyph.rs | 180 ++++++++++++++++++ src/lib.rs | 4 + tests/test_font.py | 346 ++++++++++++++++++++++++++++++++++ 6 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 src/font.rs create mode 100644 src/glyph.rs create mode 100644 tests/test_font.py diff --git a/python/pyharfrust/__init__.py b/python/pyharfrust/__init__.py index aa2f295..f65e703 100644 --- a/python/pyharfrust/__init__.py +++ b/python/pyharfrust/__init__.py @@ -3,6 +3,10 @@ Buffer, Direction, Feature, + Font, + GlyphBuffer, + GlyphInfo, + GlyphPosition, Language, Script, Variation, @@ -15,6 +19,10 @@ "Buffer", "Direction", "Feature", + "Font", + "GlyphBuffer", + "GlyphInfo", + "GlyphPosition", "Language", "Script", "Variation", diff --git a/src/buffer.rs b/src/buffer.rs index 4d4e4c1..5cfde80 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -14,7 +14,7 @@ impl PyBuffer { PyValueError::new_err("Buffer has been consumed by shape()") } - fn as_ref_buf(&self) -> PyResult<&UnicodeBuffer> { + pub(crate) fn as_ref_buf(&self) -> PyResult<&UnicodeBuffer> { self.inner.as_ref().ok_or_else(Self::consumed_err) } diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..84bb964 --- /dev/null +++ b/src/font.rs @@ -0,0 +1,178 @@ +use std::str::FromStr; + +use harfrust::{Feature, FontRef, Shaper, ShaperData, ShaperInstance, Variation}; +use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use crate::buffer::PyBuffer; +use crate::glyph::PyGlyphBuffer; +use crate::types::{PyFeature, PyVariation}; + +#[pyclass(name = "Font", unsendable)] +pub struct PyFont { + data: Vec, + face_index: u32, + shaper_data: ShaperData, + instance: Option, + point_size: Option, +} + +impl PyFont { + fn build_from_bytes(data: Vec, face_index: u32) -> PyResult { + // Validate the font and build ShaperData up front so subsequent calls + // don't need to re-parse. ShaperData is 'static — it borrows FontRef + // only during construction. + let shaper_data = { + let font = FontRef::from_index(&data, face_index) + .map_err(|e| PyRuntimeError::new_err(format!("failed to parse font: {e}")))?; + ShaperData::new(&font) + }; + Ok(Self { + data, + face_index, + shaper_data, + instance: None, + point_size: None, + }) + } + + pub(crate) fn font_ref(&self) -> PyResult> { + FontRef::from_index(&self.data, self.face_index) + .map_err(|e| PyRuntimeError::new_err(format!("failed to parse font: {e}"))) + } + + pub(crate) fn build_shaper<'a>(&'a self, font: &FontRef<'a>) -> Shaper<'a> { + self.shaper_data + .shaper(font) + .instance(self.instance.as_ref()) + .point_size(self.point_size) + .build() + } +} + +#[pymethods] +impl PyFont { + #[new] + #[pyo3(signature = (path, face_index=0))] + fn new(path: &str, face_index: u32) -> PyResult { + let data = std::fs::read(path) + .map_err(|e| PyRuntimeError::new_err(format!("failed to read {path:?}: {e}")))?; + Self::build_from_bytes(data, face_index) + } + + #[staticmethod] + #[pyo3(signature = (data, face_index=0))] + fn from_bytes(data: Vec, face_index: u32) -> PyResult { + Self::build_from_bytes(data, face_index) + } + + #[getter] + fn face_index(&self) -> u32 { + self.face_index + } + + #[getter] + fn units_per_em(&self) -> PyResult { + let font = self.font_ref()?; + Ok(self.build_shaper(&font).units_per_em()) + } + + fn set_variations(&mut self, variations: &Bound<'_, PyAny>) -> PyResult<()> { + let vars = parse_variations(variations)?; + if vars.is_empty() { + self.instance = None; + return Ok(()); + } + // Direct field access (rather than self.font_ref()) so the borrow + // checker can split self.data (immutable) from self.instance (mutable). + let font = FontRef::from_index(&self.data, self.face_index) + .map_err(|e| PyRuntimeError::new_err(format!("failed to parse font: {e}")))?; + match &mut self.instance { + Some(inst) => inst.set_variations(&font, vars), + None => { + self.instance = Some(ShaperInstance::from_variations(&font, vars)); + } + } + Ok(()) + } + + #[pyo3(signature = (size))] + fn set_point_size(&mut self, size: Option) { + self.point_size = size; + } + + #[pyo3(signature = (buffer, features=None))] + fn shape( + &self, + buffer: &mut PyBuffer, + features: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + let feats = match features { + Some(any) => parse_features(any)?, + None => Vec::new(), + }; + // Reject Invalid direction up front — harfrust panics on it otherwise, + // surfacing as PanicException in Python instead of a clean error. + if buffer.as_ref_buf()?.direction() == harfrust::Direction::Invalid { + return Err(PyValueError::new_err( + "buffer direction is unset; call buffer.guess_segment_properties() \ + or assign buffer.direction before shaping", + )); + } + let inner = buffer.take_inner()?; + let font = self.font_ref()?; + let shaper = self.build_shaper(&font); + Ok(PyGlyphBuffer::wrap(shaper.shape(inner, &feats))) + } + + fn __repr__(&self) -> String { + format!( + "Font(face_index={}, bytes={})", + self.face_index, + self.data.len() + ) + } +} + +fn parse_features(any: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(s) = any.extract::<&str>() { + return parse_csv(s, "feature", Feature::from_str); + } + if let Ok(seq) = any.extract::>() { + return Ok(seq.into_iter().map(|f| f.0).collect()); + } + Err(PyTypeError::new_err( + "expected a sequence of Feature objects or a string", + )) +} + +fn parse_variations(any: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(s) = any.extract::<&str>() { + return parse_csv(s, "variation", Variation::from_str); + } + if let Ok(seq) = any.extract::>() { + return Ok(seq.into_iter().map(|v| v.0).collect()); + } + Err(PyTypeError::new_err( + "expected a sequence of Variation objects or a string", + )) +} + +fn parse_csv(s: &str, label: &str, parse: impl Fn(&str) -> Result) -> PyResult> { + let mut out = Vec::new(); + for piece in s.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + continue; + } + let value = parse(piece) + .map_err(|_| PyValueError::new_err(format!("invalid {label}: {piece:?}")))?; + out.push(value); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/src/glyph.rs b/src/glyph.rs new file mode 100644 index 0000000..2e2b621 --- /dev/null +++ b/src/glyph.rs @@ -0,0 +1,180 @@ +use harfrust::{GlyphBuffer, GlyphInfo, GlyphPosition, SerializeFlags}; +use pyo3::exceptions::{PyIndexError, PyValueError}; +use pyo3::prelude::*; + +use crate::buffer::PyBuffer; +use crate::font::PyFont; + +// --------------------------------------------------------------------------- +// GlyphInfo +// --------------------------------------------------------------------------- + +#[pyclass(name = "GlyphInfo", frozen, from_py_object)] +#[derive(Clone, Copy)] +pub struct PyGlyphInfo { + #[pyo3(get)] + glyph_id: u32, + #[pyo3(get)] + cluster: u32, + #[pyo3(get)] + unsafe_to_break: bool, + #[pyo3(get)] + unsafe_to_concat: bool, + #[pyo3(get)] + safe_to_insert_tatweel: bool, +} + +impl PyGlyphInfo { + fn from_info(info: &GlyphInfo) -> Self { + Self { + glyph_id: info.glyph_id, + cluster: info.cluster, + unsafe_to_break: info.unsafe_to_break(), + unsafe_to_concat: info.unsafe_to_concat(), + safe_to_insert_tatweel: info.safe_to_insert_tatweel(), + } + } +} + +#[pymethods] +impl PyGlyphInfo { + fn __repr__(&self) -> String { + format!( + "GlyphInfo(glyph_id={}, cluster={})", + self.glyph_id, self.cluster + ) + } +} + +// --------------------------------------------------------------------------- +// GlyphPosition +// --------------------------------------------------------------------------- + +#[pyclass(name = "GlyphPosition", frozen, from_py_object)] +#[derive(Clone, Copy)] +pub struct PyGlyphPosition { + #[pyo3(get)] + x_advance: i32, + #[pyo3(get)] + y_advance: i32, + #[pyo3(get)] + x_offset: i32, + #[pyo3(get)] + y_offset: i32, +} + +impl PyGlyphPosition { + fn from_pos(pos: &GlyphPosition) -> Self { + Self { + x_advance: pos.x_advance, + y_advance: pos.y_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + } + } +} + +#[pymethods] +impl PyGlyphPosition { + fn __repr__(&self) -> String { + format!( + "GlyphPosition(x_advance={}, y_advance={}, x_offset={}, y_offset={})", + self.x_advance, self.y_advance, self.x_offset, self.y_offset + ) + } +} + +// --------------------------------------------------------------------------- +// GlyphBuffer +// --------------------------------------------------------------------------- + +#[pyclass(name = "GlyphBuffer", unsendable)] +pub struct PyGlyphBuffer { + inner: Option, +} + +impl PyGlyphBuffer { + pub(crate) fn wrap(buf: GlyphBuffer) -> Self { + Self { inner: Some(buf) } + } + + fn consumed_err() -> PyErr { + PyValueError::new_err("GlyphBuffer has been consumed by clear()") + } + + fn as_ref_buf(&self) -> PyResult<&GlyphBuffer> { + self.inner.as_ref().ok_or_else(Self::consumed_err) + } +} + +#[pymethods] +impl PyGlyphBuffer { + fn __len__(&self) -> PyResult { + Ok(self.as_ref_buf()?.len()) + } + + #[getter] + fn glyph_infos(&self) -> PyResult> { + Ok(self + .as_ref_buf()? + .glyph_infos() + .iter() + .map(PyGlyphInfo::from_info) + .collect()) + } + + #[getter] + fn glyph_positions(&self) -> PyResult> { + Ok(self + .as_ref_buf()? + .glyph_positions() + .iter() + .map(PyGlyphPosition::from_pos) + .collect()) + } + + // Implementing __getitem__ + __len__ makes the buffer iterable in Python + // via the legacy iteration protocol, so `for info, pos in gbuf:` works + // without a separate iterator class. + fn __getitem__(&self, index: isize) -> PyResult<(PyGlyphInfo, PyGlyphPosition)> { + let buf = self.as_ref_buf()?; + let len = buf.len() as isize; + let idx = if index < 0 { index + len } else { index }; + if idx < 0 || idx >= len { + return Err(PyIndexError::new_err("glyph index out of range")); + } + let i = idx as usize; + Ok(( + PyGlyphInfo::from_info(&buf.glyph_infos()[i]), + PyGlyphPosition::from_pos(&buf.glyph_positions()[i]), + )) + } + + fn clear(&mut self) -> PyResult { + let inner = self.inner.take().ok_or_else(Self::consumed_err)?; + Ok(PyBuffer { + inner: Some(inner.clear()), + }) + } + + fn serialize(&self, font: PyRef<'_, PyFont>) -> PyResult { + let buf = self.as_ref_buf()?; + let font_ref = font.font_ref()?; + let shaper = font.build_shaper(&font_ref); + Ok(buf.serialize(&shaper, SerializeFlags::default())) + } + + fn __repr__(&self) -> String { + match self.inner.as_ref() { + Some(b) => format!("GlyphBuffer(len={})", b.len()), + None => "GlyphBuffer()".to_string(), + } + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index bbbc035..ea63651 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ use pyo3::prelude::*; mod buffer; +mod font; +mod glyph; mod shape; mod types; @@ -9,6 +11,8 @@ fn _pyharfrust(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; types::register(m)?; buffer::register(m)?; + glyph::register(m)?; + font::register(m)?; shape::register(m)?; Ok(()) } diff --git a/tests/test_font.py b/tests/test_font.py new file mode 100644 index 0000000..e3eb65a --- /dev/null +++ b/tests/test_font.py @@ -0,0 +1,346 @@ +import os + +import pytest + +from pyharfrust import ( + Buffer, + Feature, + Font, + GlyphBuffer, + GlyphInfo, + GlyphPosition, + Variation, + shape, +) + +FONTS_DIR = os.path.join(os.path.dirname(__file__), "fonts") +PT_SANS = os.path.join(FONTS_DIR, "PT_Sans-Caption-Web-Regular.ttf") +OPEN_SANS = os.path.join(FONTS_DIR, "OpenSans.subset1.ttf") + + +def _shape_str(font, text, features=None): + buf = Buffer() + buf.add_str(text) + buf.guess_segment_properties() + return font.shape(buf, features) if features is not None else font.shape(buf) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestConstruction: + def test_from_path(self): + font = Font(PT_SANS) + assert font.face_index == 0 + assert font.units_per_em > 0 + + def test_face_index_kw(self): + font = Font(PT_SANS, face_index=0) + assert font.face_index == 0 + + def test_missing_file_raises(self): + with pytest.raises(RuntimeError): + Font("/nonexistent/font.ttf") + + def test_from_bytes(self): + with open(PT_SANS, "rb") as f: + data = f.read() + font = Font.from_bytes(data) + assert font.units_per_em > 0 + + def test_from_bytes_invalid(self): + with pytest.raises(RuntimeError): + Font.from_bytes(b"not a font file") + + +# --------------------------------------------------------------------------- +# Shaping basics +# --------------------------------------------------------------------------- + + +class TestShape: + def test_returns_glyph_buffer(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hello") + assert isinstance(gbuf, GlyphBuffer) + assert len(gbuf) == 5 + + def test_glyph_infos_and_positions(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hi") + infos = gbuf.glyph_infos + positions = gbuf.glyph_positions + assert len(infos) == len(positions) == len(gbuf) + assert all(isinstance(i, GlyphInfo) for i in infos) + assert all(isinstance(p, GlyphPosition) for p in positions) + + def test_glyph_info_fields(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB") + info = gbuf.glyph_infos[0] + assert isinstance(info.glyph_id, int) and info.glyph_id > 0 + assert info.cluster == 0 + assert isinstance(info.unsafe_to_break, bool) + assert isinstance(info.unsafe_to_concat, bool) + assert isinstance(info.safe_to_insert_tatweel, bool) + + def test_glyph_position_fields(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + pos = gbuf.glyph_positions[0] + assert pos.x_advance > 0 + assert pos.y_advance == 0 + assert isinstance(pos.x_offset, int) + assert isinstance(pos.y_offset, int) + + +# --------------------------------------------------------------------------- +# Iteration / indexing +# --------------------------------------------------------------------------- + + +class TestIteration: + def test_iter_yields_pairs(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hi") + items = list(gbuf) + assert len(items) == 2 + for info, pos in items: + assert isinstance(info, GlyphInfo) + assert isinstance(pos, GlyphPosition) + + def test_indexing(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB") + first = gbuf[0] + last = gbuf[-1] + assert first[0].cluster == 0 + assert last[0].cluster == 1 + + def test_index_out_of_range(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + with pytest.raises(IndexError): + gbuf[5] + + +# --------------------------------------------------------------------------- +# Serialize parity with shape() string function +# --------------------------------------------------------------------------- + + +class TestSerializeParity: + def test_matches_shape_string(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hello") + obj_result = gbuf.serialize(font).strip() + str_result = shape(PT_SANS, "Hello", "").strip() + assert obj_result == str_result + + def test_matches_shape_with_features(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features=[Feature("+kern")]) + obj_result = gbuf.serialize(font).strip() + str_result = shape(PT_SANS, "AB", "--features=+kern").strip() + assert obj_result == str_result + + +# --------------------------------------------------------------------------- +# Buffer consumption + recycling +# --------------------------------------------------------------------------- + + +class TestBufferConsumption: + def test_buffer_consumed_after_shape(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("Test") + buf.guess_segment_properties() + font.shape(buf) + with pytest.raises(ValueError, match="consumed"): + len(buf) + + def test_shape_twice_raises(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("Test") + buf.guess_segment_properties() + font.shape(buf) + with pytest.raises(ValueError, match="consumed"): + font.shape(buf) + + def test_shape_with_invalid_direction_raises(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("Test") # direction left at default Invalid + with pytest.raises(ValueError, match="direction"): + font.shape(buf) + + +class TestBufferRecycle: + def test_clear_returns_reusable_buffer(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("First") + buf.guess_segment_properties() + gbuf = font.shape(buf) + buf2 = gbuf.clear() + assert isinstance(buf2, Buffer) + assert len(buf2) == 0 + buf2.add_str("Second") + buf2.guess_segment_properties() + gbuf2 = font.shape(buf2) + assert len(gbuf2) == 6 + + def test_glyph_buffer_consumed_after_clear(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("X") + buf.guess_segment_properties() + gbuf = font.shape(buf) + gbuf.clear() + with pytest.raises(ValueError, match="consumed"): + len(gbuf) + + def test_clear_twice_raises(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("X") + buf.guess_segment_properties() + gbuf = font.shape(buf) + gbuf.clear() + with pytest.raises(ValueError, match="consumed"): + gbuf.clear() + + +# --------------------------------------------------------------------------- +# Features +# --------------------------------------------------------------------------- + + +class TestFeatures: + def test_features_list(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features=[Feature("+kern")]) + assert len(gbuf) == 2 + + def test_features_string(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features="+kern,-liga") + assert len(gbuf) == 2 + + def test_features_empty_list(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features=[]) + assert len(gbuf) == 2 + + def test_features_invalid_string(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("AB") + with pytest.raises(ValueError, match="invalid feature"): + font.shape(buf, features="bogus[junk") + + def test_features_wrong_type(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("AB") + with pytest.raises(TypeError): + font.shape(buf, features=42) + + +# --------------------------------------------------------------------------- +# Variations +# --------------------------------------------------------------------------- + + +class TestVariations: + def test_set_variations_list(self): + font = Font(OPEN_SANS) + font.set_variations([Variation("wght=700")]) + + def test_set_variations_string(self): + font = Font(OPEN_SANS) + font.set_variations("wght=500,wdth=80") + + def test_variations_affect_output(self): + # OpenSans.subset1 is variable; weight should change x_advance. + baseline = Font(OPEN_SANS) + bold = Font(OPEN_SANS) + bold.set_variations([Variation("wght=900")]) + + a = _shape_str(baseline, "e").glyph_positions[0].x_advance + b = _shape_str(bold, "e").glyph_positions[0].x_advance + assert a != b + + def test_set_variations_empty_resets(self): + font = Font(OPEN_SANS) + font.set_variations([Variation("wght=900")]) + bold_adv = _shape_str(font, "e").glyph_positions[0].x_advance + + font.set_variations([]) + default_adv = _shape_str(font, "e").glyph_positions[0].x_advance + assert default_adv != bold_adv + + def test_set_variations_invalid(self): + font = Font(OPEN_SANS) + with pytest.raises(ValueError, match="invalid variation"): + font.set_variations("garbage~~~") + + def test_set_variations_wrong_type(self): + font = Font(OPEN_SANS) + with pytest.raises(TypeError): + font.set_variations(42) + + +# --------------------------------------------------------------------------- +# Point size +# --------------------------------------------------------------------------- + + +class TestPointSize: + def test_set_then_clear(self): + font = Font(PT_SANS) + font.set_point_size(12.0) + font.set_point_size(None) + + def test_shape_runs_with_point_size(self): + font = Font(PT_SANS) + font.set_point_size(24.0) + gbuf = _shape_str(font, "Hi") + assert len(gbuf) == 2 + + +# --------------------------------------------------------------------------- +# Repr +# --------------------------------------------------------------------------- + + +class TestRepr: + def test_font_repr(self): + font = Font(PT_SANS) + assert "Font" in repr(font) + + def test_glyph_buffer_repr(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hi") + r = repr(gbuf) + assert "GlyphBuffer" in r and "2" in r + + def test_glyph_buffer_repr_after_clear(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "X") + gbuf.clear() + assert "consumed" in repr(gbuf) + + def test_glyph_info_repr(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + assert "GlyphInfo" in repr(gbuf.glyph_infos[0]) + + def test_glyph_position_repr(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + assert "GlyphPosition" in repr(gbuf.glyph_positions[0]) From 970305b6c1d0176a952cc47d12307c410cb1bfac Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Sat, 25 Apr 2026 23:08:20 -0700 Subject: [PATCH 2/2] Enhance glyph information tests and add byte comparison test - Updated `test_glyph_info_fields` to assert additional properties of glyph information, including cluster values for multiple glyphs. - Introduced `test_from_bytes_matches_from_path` to verify that font data loaded from bytes matches that loaded from a file path. - Adjusted error handling in `test_features_wrong_type` to ensure proper feature validation. --- tests/test_font.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/test_font.py b/tests/test_font.py index e3eb65a..f0b42fa 100644 --- a/tests/test_font.py +++ b/tests/test_font.py @@ -79,12 +79,14 @@ def test_glyph_infos_and_positions(self): def test_glyph_info_fields(self): font = Font(PT_SANS) gbuf = _shape_str(font, "AB") - info = gbuf.glyph_infos[0] - assert isinstance(info.glyph_id, int) and info.glyph_id > 0 - assert info.cluster == 0 - assert isinstance(info.unsafe_to_break, bool) - assert isinstance(info.unsafe_to_concat, bool) - assert isinstance(info.safe_to_insert_tatweel, bool) + infos = gbuf.glyph_infos + assert isinstance(infos[0].glyph_id, int) and infos[0].glyph_id > 0 + assert infos[0].cluster == 0 + assert infos[1].cluster == 1 + for info in infos: + assert isinstance(info.unsafe_to_break, bool) + assert isinstance(info.unsafe_to_concat, bool) + assert isinstance(info.safe_to_insert_tatweel, bool) def test_glyph_position_fields(self): font = Font(PT_SANS) @@ -146,6 +148,14 @@ def test_matches_shape_with_features(self): str_result = shape(PT_SANS, "AB", "--features=+kern").strip() assert obj_result == str_result + def test_from_bytes_matches_from_path(self): + font_path = Font(PT_SANS) + with open(PT_SANS, "rb") as f: + font_bytes = Font.from_bytes(f.read()) + a = _shape_str(font_path, "Hello").serialize(font_path).strip() + b = _shape_str(font_bytes, "Hello").serialize(font_bytes).strip() + assert a == b + # --------------------------------------------------------------------------- # Buffer consumption + recycling @@ -241,7 +251,7 @@ def test_features_invalid_string(self): buf = Buffer() buf.add_str("AB") with pytest.raises(ValueError, match="invalid feature"): - font.shape(buf, features="bogus[junk") + font.shape(buf, features="=") def test_features_wrong_type(self): font = Font(PT_SANS)