From 4af6756aa7c775997205a1b5dc72dd3384b41dc4 Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Wed, 22 Apr 2026 11:54:32 -0700 Subject: [PATCH 1/3] feat: Buffer class wrapping UnicodeBuffer (Phase 4) Pythonic Buffer class backed by Option, which establishes the consumption pattern for Phase 5 shape() without yet exposing a consumer. Covers the operations needed to drive shaping end-to-end: text accumulation, direction/script/language properties, guess_segment_properties, context setters, and clear. --- python/pyharfrust/__init__.py | 2 + src/buffer.rs | 158 ++++++++++++++++++++++++++++ src/lib.rs | 2 + tests/test_buffer.py | 191 ++++++++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+) create mode 100644 src/buffer.rs create mode 100644 tests/test_buffer.py diff --git a/python/pyharfrust/__init__.py b/python/pyharfrust/__init__.py index 394762b..aa2f295 100644 --- a/python/pyharfrust/__init__.py +++ b/python/pyharfrust/__init__.py @@ -1,5 +1,6 @@ from pyharfrust._pyharfrust import ( __version__, + Buffer, Direction, Feature, Language, @@ -11,6 +12,7 @@ __all__ = [ "__version__", + "Buffer", "Direction", "Feature", "Language", diff --git a/src/buffer.rs b/src/buffer.rs new file mode 100644 index 0000000..fb0217b --- /dev/null +++ b/src/buffer.rs @@ -0,0 +1,158 @@ +use harfrust::UnicodeBuffer; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::types::{PyDirection, PyLanguage, PyScript}; + +#[pyclass(name = "Buffer", unsendable)] +pub struct PyBuffer { + pub(crate) inner: Option, +} + +impl PyBuffer { + fn consumed_err() -> PyErr { + PyValueError::new_err("Buffer has been consumed by shape()") + } + + fn as_ref_buf(&self) -> PyResult<&UnicodeBuffer> { + self.inner.as_ref().ok_or_else(Self::consumed_err) + } + + fn as_mut_buf(&mut self) -> PyResult<&mut UnicodeBuffer> { + self.inner.as_mut().ok_or_else(Self::consumed_err) + } + + #[allow(dead_code)] + pub(crate) fn take_inner(&mut self) -> PyResult { + self.inner.take().ok_or_else(Self::consumed_err) + } + + #[allow(dead_code)] + pub(crate) fn restore(&mut self, buf: UnicodeBuffer) { + self.inner = Some(buf); + } +} + +#[pymethods] +impl PyBuffer { + #[new] + fn new() -> Self { + PyBuffer { + inner: Some(UnicodeBuffer::new()), + } + } + + fn __len__(&self) -> PyResult { + Ok(self.as_ref_buf()?.len()) + } + + fn add_str(&mut self, s: &str) -> PyResult<()> { + self.as_mut_buf()?.push_str(s); + Ok(()) + } + + #[pyo3(signature = (codepoint, cluster=0))] + fn add(&mut self, codepoint: u32, cluster: u32) -> PyResult<()> { + let ch = char::from_u32(codepoint).ok_or_else(|| { + PyValueError::new_err(format!("invalid unicode codepoint: U+{codepoint:04X}")) + })?; + self.as_mut_buf()?.add(ch, cluster); + Ok(()) + } + + fn clear(&mut self) -> PyResult<()> { + self.as_mut_buf()?.clear(); + Ok(()) + } + + fn reset_clusters(&mut self) -> PyResult<()> { + self.as_mut_buf()?.reset_clusters(); + Ok(()) + } + + fn guess_segment_properties(&mut self) -> PyResult<()> { + self.as_mut_buf()?.guess_segment_properties(); + Ok(()) + } + + fn reserve(&mut self, size: usize) -> PyResult { + Ok(self.as_mut_buf()?.reserve(size)) + } + + fn set_pre_context(&mut self, s: &str) -> PyResult<()> { + self.as_mut_buf()?.set_pre_context(s); + Ok(()) + } + + fn set_post_context(&mut self, s: &str) -> PyResult<()> { + self.as_mut_buf()?.set_post_context(s); + Ok(()) + } + + fn set_not_found_variation_selector_glyph(&mut self, glyph: u32) -> PyResult<()> { + self.as_mut_buf()? + .set_not_found_variation_selector_glyph(glyph); + Ok(()) + } + + #[getter] + fn direction(&self) -> PyResult { + Ok(PyDirection(self.as_ref_buf()?.direction())) + } + + #[setter] + fn set_direction(&mut self, d: PyDirection) -> PyResult<()> { + self.as_mut_buf()?.set_direction(d.0); + Ok(()) + } + + #[getter] + fn script(&self) -> PyResult { + Ok(PyScript(self.as_ref_buf()?.script())) + } + + #[setter] + fn set_script(&mut self, s: PyScript) -> PyResult<()> { + self.as_mut_buf()?.set_script(s.0); + Ok(()) + } + + #[getter] + fn language(&self) -> PyResult> { + Ok(self.as_ref_buf()?.language().map(PyLanguage)) + } + + #[setter] + fn set_language(&mut self, l: PyLanguage) -> PyResult<()> { + self.as_mut_buf()?.set_language(l.0); + Ok(()) + } + + fn __repr__(&self) -> PyResult { + let buf = self.as_ref_buf()?; + let dir = match buf.direction() { + harfrust::Direction::LeftToRight => "Direction.LTR", + harfrust::Direction::RightToLeft => "Direction.RTL", + harfrust::Direction::TopToBottom => "Direction.TTB", + harfrust::Direction::BottomToTop => "Direction.BTT", + harfrust::Direction::Invalid => "Direction()", + }; + let script = buf.script().tag().to_string(); + let lang = buf + .language() + .map(|l| format!("\"{}\"", l.as_str())) + .unwrap_or_else(|| "None".to_string()); + Ok(format!( + "Buffer(len={}, direction={}, script=\"{}\", language={})", + buf.len(), + dir, + script, + lang, + )) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 45b7f9e..bbbc035 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ use pyo3::prelude::*; +mod buffer; mod shape; mod types; @@ -7,6 +8,7 @@ mod types; fn _pyharfrust(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; types::register(m)?; + buffer::register(m)?; shape::register(m)?; Ok(()) } diff --git a/tests/test_buffer.py b/tests/test_buffer.py new file mode 100644 index 0000000..9b2602e --- /dev/null +++ b/tests/test_buffer.py @@ -0,0 +1,191 @@ +import pytest + +from pyharfrust import Buffer, Direction, Language, Script + + +# --------------------------------------------------------------------------- +# Construction / length +# --------------------------------------------------------------------------- + + +class TestCreation: + def test_empty(self): + buf = Buffer() + assert len(buf) == 0 + + def test_add_str_ascii(self): + buf = Buffer() + buf.add_str("Hello") + assert len(buf) == 5 + + def test_add_str_multi_bmp(self): + buf = Buffer() + buf.add_str("العربية") + assert len(buf) == 7 + + def test_add_str_astral(self): + buf = Buffer() + buf.add_str("A\U0001f600B") + assert len(buf) == 3 + + def test_add_str_empty(self): + buf = Buffer() + buf.add_str("") + assert len(buf) == 0 + + def test_add_str_multiple_calls_append(self): + buf = Buffer() + buf.add_str("Hel") + buf.add_str("lo") + assert len(buf) == 5 + + def test_add_codepoint(self): + buf = Buffer() + buf.add(0x0041) + buf.add(0x0042, cluster=7) + assert len(buf) == 2 + + +# --------------------------------------------------------------------------- +# Clear / reset +# --------------------------------------------------------------------------- + + +class TestClear: + def test_clear_empties(self): + buf = Buffer() + buf.add_str("hello") + buf.clear() + assert len(buf) == 0 + + def test_clear_allows_reuse(self): + buf = Buffer() + buf.add_str("one") + buf.clear() + buf.add_str("two") + assert len(buf) == 3 + + def test_reset_clusters(self): + buf = Buffer() + buf.add_str("abc") + buf.reset_clusters() + assert len(buf) == 3 + + +# --------------------------------------------------------------------------- +# Direction / Script / Language properties +# --------------------------------------------------------------------------- + + +class TestProperties: + def test_set_get_direction(self): + buf = Buffer() + buf.direction = Direction.RTL + assert buf.direction == Direction.RTL + buf.direction = Direction.LTR + assert buf.direction == Direction.LTR + + def test_set_get_script(self): + buf = Buffer() + buf.script = Script("Arab") + assert buf.script == Script("Arab") + buf.script = Script("Latn") + assert buf.script == Script("Latn") + + def test_set_get_language(self): + buf = Buffer() + buf.language = Language("en") + assert buf.language == Language("en") + buf.language = Language("ar") + assert buf.language == Language("ar") + + def test_language_none_when_unset(self): + buf = Buffer() + assert buf.language is None + + def test_direction_setter_rejects_nondirection(self): + buf = Buffer() + with pytest.raises(TypeError): + buf.direction = "ltr" + + def test_script_setter_rejects_nonscript(self): + buf = Buffer() + with pytest.raises(TypeError): + buf.script = "Latn" + + def test_language_setter_rejects_nonlanguage(self): + buf = Buffer() + with pytest.raises(TypeError): + buf.language = "en" + + +# --------------------------------------------------------------------------- +# Context setters +# --------------------------------------------------------------------------- + + +class TestContext: + def test_set_pre_context(self): + buf = Buffer() + buf.set_pre_context("abc") + # no getter in upstream API; just verify the call succeeds + + def test_set_post_context(self): + buf = Buffer() + buf.set_post_context("xyz") + + +# --------------------------------------------------------------------------- +# guess_segment_properties +# --------------------------------------------------------------------------- + + +class TestGuessSegmentProperties: + def test_arabic_guesses_rtl(self): + buf = Buffer() + buf.add_str("العربية") + buf.guess_segment_properties() + assert buf.direction == Direction.RTL + assert buf.script == Script("Arab") + + def test_latin_guesses_ltr(self): + buf = Buffer() + buf.add_str("Hello") + buf.guess_segment_properties() + assert buf.direction == Direction.LTR + assert buf.script == Script("Latn") + + +# --------------------------------------------------------------------------- +# reserve +# --------------------------------------------------------------------------- + + +class TestReserve: + def test_reserve_returns_bool(self): + buf = Buffer() + result = buf.reserve(128) + assert isinstance(result, bool) + assert result is True + + def test_reserve_does_not_change_length(self): + buf = Buffer() + buf.add_str("hi") + buf.reserve(1024) + assert len(buf) == 2 + + +# --------------------------------------------------------------------------- +# repr +# --------------------------------------------------------------------------- + + +class TestRepr: + def test_repr_mentions_class(self): + buf = Buffer() + assert "Buffer" in repr(buf) + + def test_repr_shows_length(self): + buf = Buffer() + buf.add_str("hi") + assert "2" in repr(buf) From d75419d021db920e9948ccdf7d8a8893b220f712 Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Wed, 22 Apr 2026 12:15:27 -0700 Subject: [PATCH 2/3] Test set_not_found_variation_selector_glyph and flag repr duplication Adds a Buffer test covering set_not_found_variation_selector_glyph, which was exposed but previously unexercised. Marks the Direction label match inside Buffer.__repr__ with a comment pointing at the equivalent (private) match in PyDirection::__repr__, so the next edit to either keeps them in sync. Expands the TestContext header to spell out why pre/post context tests only assert that the call doesn't raise. --- src/buffer.rs | 3 +++ tests/test_buffer.py | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/buffer.rs b/src/buffer.rs index fb0217b..4d4e4c1 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -130,6 +130,9 @@ impl PyBuffer { fn __repr__(&self) -> PyResult { let buf = self.as_ref_buf()?; + // NOTE: duplicated from PyDirection::__repr__ in types.rs (private there). + // If Direction's repr labels ever change, update both sites — or promote + // this match to a shared helper. let dir = match buf.direction() { harfrust::Direction::LeftToRight => "Direction.LTR", harfrust::Direction::RightToLeft => "Direction.RTL", diff --git a/tests/test_buffer.py b/tests/test_buffer.py index 9b2602e..656596b 100644 --- a/tests/test_buffer.py +++ b/tests/test_buffer.py @@ -121,6 +121,10 @@ def test_language_setter_rejects_nonlanguage(self): # --------------------------------------------------------------------------- # Context setters +# +# harfrust exposes only setters for pre/post context (no getters), so these +# tests can only confirm the calls don't raise. The real regression signal +# will come from Phase 5 shaping tests where context changes affect output. # --------------------------------------------------------------------------- @@ -128,12 +132,16 @@ class TestContext: def test_set_pre_context(self): buf = Buffer() buf.set_pre_context("abc") - # no getter in upstream API; just verify the call succeeds def test_set_post_context(self): buf = Buffer() buf.set_post_context("xyz") + def test_set_not_found_variation_selector_glyph(self): + buf = Buffer() + buf.set_not_found_variation_selector_glyph(0) + buf.set_not_found_variation_selector_glyph(42) + # --------------------------------------------------------------------------- # guess_segment_properties From 5c97674eb932454dab72030e2e1ee7e38a43eba5 Mon Sep 17 00:00:00 2001 From: Hasan Zakeri Date: Sat, 25 Apr 2026 03:20:01 -0700 Subject: [PATCH 3/3] docs: update development plan link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 082b5b6..b6f5d90 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ HarfBuzz is the industry-standard text shaping engine used by Firefox, Chrome, A ## Status -This project is in early development. See the [development plan](https://github.com/nickkuk/harfrust-python/blob/main/ROADMAP.md) for the phased roadmap. +This project is in early development. See the [development plan](https://github.com/hasanzakeri/harfrust-python/blob/main/ROADMAP.md) for the phased roadmap. ## License