From 4e3e01db1f8148e03e3456239012f43ce4cd64d9 Mon Sep 17 00:00:00 2001 From: Chad Brokaw Date: Mon, 5 Oct 2026 17:27:23 -0400 Subject: [PATCH] Add set and lookup glyph collection to C API Expose hr_set and GSUB/GPOS presence and lookup count queries. Collect glyph roles from direct and contextual lookup subtables, including nested GSUB substitutions, using read-fonts without temporary sets. Add focused C API regression tests and an optional differential harness. Compared 79 lookups across five fonts with HarfBuzz and found no set differences; all 115 C API tests and Clippy pass. --- harfrust_capi/README.md | 4 +- harfrust_capi/include/hr-hb.h | 15 + harfrust_capi/include/hr.h | 97 +++++ harfrust_capi/src/lib.rs | 8 +- harfrust_capi/src/ot_layout.rs | 490 ++++++++++++++++++++++- harfrust_capi/src/set.rs | 130 ++++++ harfrust_capi/tests/capi.rs | 120 ++++++ harfrust_capi/tests/compare_layout_hb.py | 100 +++++ 8 files changed, 958 insertions(+), 6 deletions(-) create mode 100644 harfrust_capi/src/set.rs create mode 100644 harfrust_capi/tests/compare_layout_hb.py diff --git a/harfrust_capi/README.md b/harfrust_capi/README.md index 68c787e9..46e63e36 100644 --- a/harfrust_capi/README.md +++ b/harfrust_capi/README.md @@ -118,8 +118,8 @@ HarfRust is a shaping library, so anything outside shaping is absent: - Drawing and painting callbacks (`hb_draw_funcs_t`, `hb_paint_funcs_t`). - Subsetting. -- Other layout table introspection (`hb_ot_layout_*`), and the `hb_set` / - `hb_map` containers it reports through. +- Layout queries beyond the available GSUB/GPOS presence, lookup count, and + glyph collection functions; and the `hb_map` container. - Custom Unicode callbacks (`hb_unicode_funcs_t`); HarfRust's own Unicode data is always used. - `hb_buffer_diff`, buffer message callbacks, and `hb_font_get_glyph_name`. diff --git a/harfrust_capi/include/hr-hb.h b/harfrust_capi/include/hr-hb.h index ebdf0912..c9c9d594 100644 --- a/harfrust_capi/include/hr-hb.h +++ b/harfrust_capi/include/hr-hb.h @@ -211,6 +211,10 @@ #define hb_ot_layout_get_baseline_with_fallback hr_ot_layout_get_baseline_with_fallback #define hb_ot_layout_get_baseline_with_fallback2 hr_ot_layout_get_baseline_with_fallback2 #define hb_ot_layout_get_horizontal_baseline_tag_for_script hr_ot_layout_get_horizontal_baseline_tag_for_script +#define hb_ot_layout_has_positioning hr_ot_layout_has_positioning +#define hb_ot_layout_has_substitution hr_ot_layout_has_substitution +#define hb_ot_layout_lookup_collect_glyphs hr_ot_layout_lookup_collect_glyphs +#define hb_ot_layout_table_get_lookup_count hr_ot_layout_table_get_lookup_count #define hb_ot_math_constant_t hr_ot_math_constant_t #define hb_ot_math_get_constant hr_ot_math_get_constant #define hb_ot_math_get_glyph_assembly hr_ot_math_get_glyph_assembly @@ -238,6 +242,17 @@ #define hb_segment_properties_hash hr_segment_properties_hash #define hb_segment_properties_overlay hr_segment_properties_overlay #define hb_segment_properties_t hr_segment_properties_t +#define hb_set_add hr_set_add +#define hb_set_clear hr_set_clear +#define hb_set_create hr_set_create +#define hb_set_destroy hr_set_destroy +#define hb_set_get_empty hr_set_get_empty +#define hb_set_get_user_data hr_set_get_user_data +#define hb_set_has hr_set_has +#define hb_set_is_empty hr_set_is_empty +#define hb_set_reference hr_set_reference +#define hb_set_set_user_data hr_set_set_user_data +#define hb_set_t hr_set_t #define hb_shape hr_shape #define hb_shape_full hr_shape_full #define hb_shape_list_shapers hr_shape_list_shapers diff --git a/harfrust_capi/include/hr.h b/harfrust_capi/include/hr.h index 89c632e5..84ce469c 100644 --- a/harfrust_capi/include/hr.h +++ b/harfrust_capi/include/hr.h @@ -87,6 +87,8 @@ typedef struct hr_font_t hr_font_t; */ typedef struct hr_language_impl_t hr_language_impl_t; +typedef struct hr_set_t hr_set_t; + /** * A reusable plan for shaping text with given properties. */ @@ -3486,6 +3488,45 @@ void hr_font_funcs_set_glyph_extents_func(struct hr_font_funcs_t *ffuncs, void *user_data, hr_destroy_func_t destroy); +/** + * Returns whether the face has a readable GSUB table. + * + * # Safety + * `face` must be `NULL` or live. + */ +hr_bool_t hr_ot_layout_has_substitution(struct hr_face_t *face); + +/** + * Returns whether the face has a readable GPOS table. + * + * # Safety + * `face` must be `NULL` or live. + */ +hr_bool_t hr_ot_layout_has_positioning(struct hr_face_t *face); + +/** + * Returns the number of lookups in GSUB or GPOS. + * + * # Safety + * `face` must be `NULL` or live. + */ +unsigned int hr_ot_layout_table_get_lookup_count(struct hr_face_t *face, hr_tag_t table_tag); + +/** + * Collects glyphs touched by one GSUB or GPOS lookup into the supplied sets. + * Forms not yet enumerated conservatively include every OpenType glyph ID. + * + * # Safety + * `face` and each non-`NULL` set must be live. Output sets may be the same. + */ +void hr_ot_layout_lookup_collect_glyphs(struct hr_face_t *face, + hr_tag_t table_tag, + unsigned int lookup_index, + struct hr_set_t *glyphs_before, + struct hr_set_t *glyphs_input, + struct hr_set_t *glyphs_after, + struct hr_set_t *glyphs_output); + /** * Returns the dominant horizontal baseline for a Unicode script. */ @@ -3638,6 +3679,62 @@ unsigned int hr_ot_math_get_glyph_assembly(struct hr_font_t *font, struct hr_ot_math_glyph_part_t *parts, hr_position_t *italics_correction); +struct hr_set_t *hr_set_create(void); + +struct hr_set_t *hr_set_get_empty(void); + +/** + * # Safety + * `set` must be `NULL` or live. + */ +struct hr_set_t *hr_set_reference(struct hr_set_t *set); + +/** + * # Safety + * `set` must be `NULL` or live, and the caller must own its reference. + */ +void hr_set_destroy(struct hr_set_t *set); + +/** + * # Safety + * `set` must be `NULL` or live, and `key` must outlive it. + */ +hr_bool_t hr_set_set_user_data(struct hr_set_t *set, + const struct hr_user_data_key_t *key, + void *data, + hr_destroy_func_t destroy, + hr_bool_t replace); + +/** + * # Safety + * `set` must be `NULL` or live. + */ +void *hr_set_get_user_data(struct hr_set_t *set, const struct hr_user_data_key_t *key); + +/** + * # Safety + * `set` must be `NULL` or live. + */ +void hr_set_clear(struct hr_set_t *set); + +/** + * # Safety + * `set` must be `NULL` or live. + */ +void hr_set_add(struct hr_set_t *set, hr_codepoint_t value); + +/** + * # Safety + * `set` must be `NULL` or live. + */ +hr_bool_t hr_set_has(const struct hr_set_t *set, hr_codepoint_t value); + +/** + * # Safety + * `set` must be `NULL` or live. + */ +hr_bool_t hr_set_is_empty(const struct hr_set_t *set); + /** * Shapes a buffer with a font, applying the given features. * diff --git a/harfrust_capi/src/lib.rs b/harfrust_capi/src/lib.rs index ad73084c..afac20ac 100644 --- a/harfrust_capi/src/lib.rs +++ b/harfrust_capi/src/lib.rs @@ -10,9 +10,9 @@ as HarfBuzz itself without collisions. # Scope This covers shaping: blobs, faces, fonts, buffers and `hr_shape`, plus OpenType -`BASE` baseline and `MATH` queries. It has no drawing or painting callbacks, no -subsetting, no other layout table introspection and no `hb_set` / `hb_map` -containers, because HarfRust does not provide them. +`BASE` baseline and `MATH` queries. It also provides the layout lookup queries +and sets used to inspect glyph participation. Drawing and painting callbacks, +subsetting, and maps are outside this API. # Object lifetime @@ -38,6 +38,7 @@ pub mod object; pub mod ot_layout; pub mod ot_math; mod plan; +pub mod set; pub mod shape; pub mod shape_plan; @@ -50,6 +51,7 @@ pub use font_funcs::*; pub use object::{hr_destroy_func_t, hr_user_data_key_t}; pub use ot_layout::*; pub use ot_math::*; +pub use set::*; pub use shape::*; pub use shape_plan::*; diff --git a/harfrust_capi/src/ot_layout.rs b/harfrust_capi/src/ot_layout.rs index 616ecab1..71f86259 100644 --- a/harfrust_capi/src/ot_layout.rs +++ b/harfrust_capi/src/ot_layout.rs @@ -1,17 +1,505 @@ //! OpenType BASE baseline queries. +use core::ffi::c_uint; + use harfrust::Tag; use read_fonts::tables::base::{BaseAxis, BaseInstance}; +use read_fonts::tables::gpos::{PairPos, PositionSubtables}; +use read_fonts::tables::gsub::{SingleSubst, SubstitutionLookupList, SubstitutionSubtables}; +use read_fonts::tables::layout::{ + ChainedSequenceContext, ClassDef, CoverageTable, SequenceContext, SequenceLookupRecord, +}; use read_fonts::types::F48Dot16; -use read_fonts::TableProvider; +use read_fonts::{ReadError, TableProvider}; use crate::common::hr_glyph_extents_t; use crate::common::{ hr_bool_t, hr_direction_t, hr_language_t, hr_position_t, hr_script_t, hr_tag_t, script_to_rust, tag_from_rust, tag_to_rust, HR_DIRECTION_LTR, HR_DIRECTION_RTL, }; +use crate::face::hr_face_t; use crate::font::{hr_font_get_glyph_extents, hr_font_get_nominal_glyph, hr_font_t}; use crate::object; +use crate::set::hr_set_t; + +const GSUB_TAG: hr_tag_t = 0x4753_5542; +const GPOS_TAG: hr_tag_t = 0x4750_4F53; + +/// Returns whether the face has a readable GSUB table. +/// +/// # Safety +/// `face` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_ot_layout_has_substitution(face: *mut hr_face_t) -> hr_bool_t { + let face = unsafe { object::or_empty(face.cast_const()) }; + face.font() + .is_some_and(|font| font.tables().gsub().is_ok()) + .into() +} + +/// Returns whether the face has a readable GPOS table. +/// +/// # Safety +/// `face` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_ot_layout_has_positioning(face: *mut hr_face_t) -> hr_bool_t { + let face = unsafe { object::or_empty(face.cast_const()) }; + face.font() + .is_some_and(|font| font.tables().gpos().is_ok()) + .into() +} + +/// Returns the number of lookups in GSUB or GPOS. +/// +/// # Safety +/// `face` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_ot_layout_table_get_lookup_count( + face: *mut hr_face_t, + table_tag: hr_tag_t, +) -> c_uint { + let face = unsafe { object::or_empty(face.cast_const()) }; + let Some(font) = face.font() else { return 0 }; + match table_tag { + GSUB_TAG => font + .tables() + .gsub() + .ok() + .and_then(|table| table.lookup_list().ok()) + .map_or(0, |list| list.lookups().len() as c_uint), + GPOS_TAG => font + .tables() + .gpos() + .ok() + .and_then(|table| table.lookup_list().ok()) + .map_or(0, |list| list.lookups().len() as c_uint), + _ => 0, + } +} + +#[derive(Clone, Copy)] +struct SetTarget(*mut hr_set_t); + +impl SetTarget { + fn add(self, glyph: u32) { + if let Some(set) = unsafe { object::as_mutable(self.0) } { + set.add(glyph); + } + } + + fn include_all_glyphs(self) { + if let Some(set) = unsafe { object::as_mutable(self.0) } { + set.include_all_glyphs(65536); + } + } +} + +fn add_coverage(set: SetTarget, coverage: CoverageTable<'_>) { + for glyph in coverage.iter() { + set.add(glyph.to_u32()); + } +} + +fn add_class_coverage(set: SetTarget, class_def: ClassDef<'_>) { + match class_def { + ClassDef::Format1(table) => { + let start = table.start_glyph_id().to_u32(); + let mut previous_nonzero = false; + for (index, class) in table.class_value_array().iter().enumerate() { + let nonzero = class.get() != 0; + if nonzero || previous_nonzero { + set.add(start + index as u32); + } + previous_nonzero = nonzero; + } + if previous_nonzero { + set.add(start + table.class_value_array().len() as u32); + } + } + ClassDef::Format2(table) => { + for (glyph, class) in table.iter() { + if class != 0 { + set.add(glyph.to_u32()); + } + } + } + } +} + +fn add_class(set: SetTarget, class_def: &ClassDef<'_>, selected: u16) { + for (glyph, class) in class_def.iter() { + if class == selected { + set.add(glyph.to_u32()); + } + } +} + +fn collect_nested_substitutions( + records: &[SequenceLookupRecord], + lookup_list: &SubstitutionLookupList<'_>, + output: SetTarget, + depth: u8, +) -> Result { + for record in records { + let nested = lookup_list + .lookups() + .get(record.lookup_list_index() as usize)?; + if !collect_substitution( + nested, + lookup_list, + SetTarget(core::ptr::null_mut()), + SetTarget(core::ptr::null_mut()), + SetTarget(core::ptr::null_mut()), + output, + depth + 1, + )? { + return Ok(false); + } + } + Ok(true) +} + +fn collect_substitution( + lookup: read_fonts::tables::gsub::SubstitutionLookup<'_>, + lookup_list: &SubstitutionLookupList<'_>, + before: SetTarget, + input: SetTarget, + after: SetTarget, + output: SetTarget, + depth: u8, +) -> Result { + if depth >= 8 { + return Ok(false); + } + match lookup.subtables()? { + SubstitutionSubtables::Single(subtables) => { + for subtable in subtables.iter() { + match subtable? { + SingleSubst::Format1(table) => { + let coverage = table.coverage()?; + for glyph in coverage.iter() { + let id = glyph.to_u32(); + input.add(id); + output.add((id as u16).wrapping_add_signed(table.delta_glyph_id()) as u32); + } + } + SingleSubst::Format2(table) => { + add_coverage(input, table.coverage()?); + for glyph in table.substitute_glyph_ids() { + output.add(glyph.get().to_u32()); + } + } + } + } + } + SubstitutionSubtables::Multiple(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.coverage()?); + for sequence in table.sequences().iter() { + for glyph in sequence?.substitute_glyph_ids() { + output.add(glyph.get().to_u32()); + } + } + } + } + SubstitutionSubtables::Alternate(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.coverage()?); + for alternate in table.alternate_sets().iter() { + for glyph in alternate?.alternate_glyph_ids() { + output.add(glyph.get().to_u32()); + } + } + } + } + SubstitutionSubtables::Ligature(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.coverage()?); + for set in table.ligature_sets().iter() { + for ligature in set?.ligatures().iter() { + let ligature = ligature?; + output.add(ligature.ligature_glyph().to_u32()); + for glyph in ligature.component_glyph_ids() { + input.add(glyph.get().to_u32()); + } + } + } + } + } + SubstitutionSubtables::Reverse(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.coverage()?); + for coverage in table.backtrack_coverages().iter() { + add_coverage(before, coverage?); + } + for coverage in table.lookahead_coverages().iter() { + add_coverage(after, coverage?); + } + for glyph in table.substitute_glyph_ids() { + output.add(glyph.get().to_u32()); + } + } + } + SubstitutionSubtables::Contextual(subtables) => { + for subtable in subtables.iter() { + match subtable? { + SequenceContext::Format1(table) => { + add_coverage(input, table.coverage()?); + for rule_set in table.seq_rule_sets().iter().flatten() { + for rule in rule_set?.seq_rules().iter() { + let rule = rule?; + for glyph in rule.input_sequence() { + input.add(glyph.get().to_u32()); + } + if !collect_nested_substitutions( + rule.seq_lookup_records(), + lookup_list, + output, + depth, + )? { + return Ok(false); + } + } + } + } + SequenceContext::Format2(table) => { + add_coverage(input, table.coverage()?); + let class_def = table.class_def()?; + for rule_set in table.class_seq_rule_sets().iter().flatten() { + for rule in rule_set?.class_seq_rules().iter() { + let rule = rule?; + for class in rule.input_sequence() { + add_class(input, &class_def, class.get()); + } + if !collect_nested_substitutions( + rule.seq_lookup_records(), + lookup_list, + output, + depth, + )? { + return Ok(false); + } + } + } + } + SequenceContext::Format3(table) => { + for coverage in table.coverages().iter() { + add_coverage(input, coverage?); + } + if !collect_nested_substitutions( + table.seq_lookup_records(), + lookup_list, + output, + depth, + )? { + return Ok(false); + } + } + } + } + } + SubstitutionSubtables::ChainContextual(subtables) => { + for subtable in subtables.iter() { + match subtable? { + ChainedSequenceContext::Format1(table) => { + add_coverage(input, table.coverage()?); + for rule_set in table.chained_seq_rule_sets().iter().flatten() { + for rule in rule_set?.chained_seq_rules().iter() { + let rule = rule?; + for glyph in rule.backtrack_sequence() { + before.add(glyph.get().to_u32()); + } + for glyph in rule.input_sequence() { + input.add(glyph.get().to_u32()); + } + for glyph in rule.lookahead_sequence() { + after.add(glyph.get().to_u32()); + } + if !collect_nested_substitutions( + rule.seq_lookup_records(), + lookup_list, + output, + depth, + )? { + return Ok(false); + } + } + } + } + ChainedSequenceContext::Format2(table) => { + add_coverage(input, table.coverage()?); + let backtrack_class_def = table.backtrack_class_def()?; + let input_class_def = table.input_class_def()?; + let lookahead_class_def = table.lookahead_class_def()?; + for rule_set in table.chained_class_seq_rule_sets().iter().flatten() { + for rule in rule_set?.chained_class_seq_rules().iter() { + let rule = rule?; + for class in rule.backtrack_sequence() { + add_class(before, &backtrack_class_def, class.get()); + } + for class in rule.input_sequence() { + add_class(input, &input_class_def, class.get()); + } + for class in rule.lookahead_sequence() { + add_class(after, &lookahead_class_def, class.get()); + } + if !collect_nested_substitutions( + rule.seq_lookup_records(), + lookup_list, + output, + depth, + )? { + return Ok(false); + } + } + } + } + ChainedSequenceContext::Format3(table) => { + let mut coverages = table.input_coverages().iter(); + add_coverage(input, coverages.next().ok_or(ReadError::OutOfBounds)??); + for coverage in coverages { + add_coverage(input, coverage?); + } + for coverage in table.backtrack_coverages().iter() { + add_coverage(before, coverage?); + } + for coverage in table.lookahead_coverages().iter() { + add_coverage(after, coverage?); + } + if !collect_nested_substitutions( + table.seq_lookup_records(), + lookup_list, + output, + depth, + )? { + return Ok(false); + } + } + } + } + } + SubstitutionSubtables::EmptyExtension => {} + } + Ok(true) +} + +fn collect_positioning( + lookup: read_fonts::tables::gpos::PositionLookup<'_>, + input: SetTarget, +) -> Result { + match lookup.subtables()? { + PositionSubtables::Single(subtables) => { + for table in subtables.iter() { + match table? { + read_fonts::tables::gpos::SinglePos::Format1(table) => { + add_coverage(input, table.coverage()?); + } + read_fonts::tables::gpos::SinglePos::Format2(table) => { + add_coverage(input, table.coverage()?); + } + } + } + } + PositionSubtables::Pair(subtables) => { + for table in subtables.iter() { + match table? { + PairPos::Format1(table) => { + add_coverage(input, table.coverage()?); + for pair_set in table.pair_sets().iter() { + for record in pair_set?.pair_value_records().iter() { + input.add(record?.second_glyph().to_u32()); + } + } + } + PairPos::Format2(table) => { + add_coverage(input, table.coverage()?); + add_class_coverage(input, table.class_def2()?); + } + } + } + } + PositionSubtables::Cursive(subtables) => { + for table in subtables.iter() { + add_coverage(input, table?.coverage()?); + } + } + PositionSubtables::MarkToBase(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.mark_coverage()?); + add_coverage(input, table.base_coverage()?); + } + } + PositionSubtables::MarkToLig(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.mark_coverage()?); + add_coverage(input, table.ligature_coverage()?); + } + } + PositionSubtables::MarkToMark(subtables) => { + for table in subtables.iter() { + let table = table?; + add_coverage(input, table.mark1_coverage()?); + add_coverage(input, table.mark2_coverage()?); + } + } + PositionSubtables::EmptyExtension => {} + _ => return Ok(false), + } + Ok(true) +} + +/// Collects glyphs touched by one GSUB or GPOS lookup into the supplied sets. +/// Forms not yet enumerated conservatively include every OpenType glyph ID. +/// +/// # Safety +/// `face` and each non-`NULL` set must be live. Output sets may be the same. +#[no_mangle] +pub unsafe extern "C" fn hr_ot_layout_lookup_collect_glyphs( + face: *mut hr_face_t, + table_tag: hr_tag_t, + lookup_index: c_uint, + glyphs_before: *mut hr_set_t, + glyphs_input: *mut hr_set_t, + glyphs_after: *mut hr_set_t, + glyphs_output: *mut hr_set_t, +) { + let face = unsafe { object::or_empty(face.cast_const()) }; + let Some(font) = face.font() else { return }; + // Each insertion borrows its destination separately, so output pointers may alias. + let before = SetTarget(glyphs_before); + let input = SetTarget(glyphs_input); + let after = SetTarget(glyphs_after); + let output = SetTarget(glyphs_output); + let complete = match table_tag { + GSUB_TAG => font.tables().gsub().ok().and_then(|table| { + let list = table.lookup_list().ok()?; + let lookup = list.lookups().get(lookup_index as usize).ok()?; + Some(collect_substitution( + lookup, &list, before, input, after, output, 0, + )) + }), + GPOS_TAG => font + .tables() + .gpos() + .ok() + .and_then(|table| table.lookup_list().ok()) + .and_then(|list| list.lookups().get(lookup_index as usize).ok()) + .map(|lookup| collect_positioning(lookup, input)), + _ => None, + }; + let Some(complete) = complete else { return }; + if !matches!(complete, Ok(true)) { + for set in [before, input, after, output] { + set.include_all_glyphs(); + } + } +} /// A registered OpenType BASE baseline tag. The numeric value is the tag itself. pub type hr_ot_layout_baseline_tag_t = hr_tag_t; diff --git a/harfrust_capi/src/set.rs b/harfrust_capi/src/set.rs new file mode 100644 index 00000000..46a436cf --- /dev/null +++ b/harfrust_capi/src/set.rs @@ -0,0 +1,130 @@ +//! Sets of codepoints and glyph IDs used by layout queries. + +use core::ffi::c_void; +use read_fonts::collections::U32Set; +use std::sync::OnceLock; + +use crate::common::{hr_bool_t, hr_codepoint_t, HR_CODEPOINT_INVALID}; +use crate::object::{self, hr_destroy_func_t, hr_user_data_key_t, Empty, Object, ObjectHeader}; + +pub struct hr_set_t { + header: ObjectHeader, + pub(crate) values: U32Set, +} + +static EMPTY_SET: OnceLock> = OnceLock::new(); + +impl Object for hr_set_t { + fn header(&self) -> &ObjectHeader { + &self.header + } + + fn empty() -> *mut Self { + EMPTY_SET + .get_or_init(|| { + Empty::new(hr_set_t { + header: ObjectHeader::immortal(), + values: U32Set::default(), + }) + }) + .get() + } +} + +impl hr_set_t { + pub(crate) fn add(&mut self, value: u32) { + if value != HR_CODEPOINT_INVALID { + self.values.insert(value); + } + } + + pub(crate) fn include_all_glyphs(&mut self, count: u32) { + if count > 0 { + self.values.insert_range(0..=count - 1); + } + } +} + +#[no_mangle] +pub extern "C" fn hr_set_create() -> *mut hr_set_t { + object::create(hr_set_t { + header: ObjectHeader::new(), + values: U32Set::default(), + }) +} + +#[no_mangle] +pub extern "C" fn hr_set_get_empty() -> *mut hr_set_t { + hr_set_t::empty() +} + +/// # Safety +/// `set` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_set_reference(set: *mut hr_set_t) -> *mut hr_set_t { + unsafe { object::reference(set) } +} + +/// # Safety +/// `set` must be `NULL` or live, and the caller must own its reference. +#[no_mangle] +pub unsafe extern "C" fn hr_set_destroy(set: *mut hr_set_t) { + unsafe { object::destroy(set) }; +} + +/// # Safety +/// `set` must be `NULL` or live, and `key` must outlive it. +#[no_mangle] +pub unsafe extern "C" fn hr_set_set_user_data( + set: *mut hr_set_t, + key: *const hr_user_data_key_t, + data: *mut c_void, + destroy: hr_destroy_func_t, + replace: hr_bool_t, +) -> hr_bool_t { + unsafe { object::set_user_data(set, key, data, destroy, replace != 0) }.into() +} + +/// # Safety +/// `set` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_set_get_user_data( + set: *mut hr_set_t, + key: *const hr_user_data_key_t, +) -> *mut c_void { + unsafe { object::get_user_data(set, key) } +} + +/// # Safety +/// `set` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_set_clear(set: *mut hr_set_t) { + if let Some(set) = unsafe { object::as_mutable(set) } { + set.values.clear(); + } +} + +/// # Safety +/// `set` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_set_add(set: *mut hr_set_t, value: hr_codepoint_t) { + if let Some(set) = unsafe { object::as_mutable(set) } { + set.add(value); + } +} + +/// # Safety +/// `set` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_set_has(set: *const hr_set_t, value: hr_codepoint_t) -> hr_bool_t { + let set = unsafe { object::or_empty(set) }; + (value != HR_CODEPOINT_INVALID && set.values.contains(value)).into() +} + +/// # Safety +/// `set` must be `NULL` or live. +#[no_mangle] +pub unsafe extern "C" fn hr_set_is_empty(set: *const hr_set_t) -> hr_bool_t { + let set = unsafe { object::or_empty(set) }; + set.values.is_empty().into() +} diff --git a/harfrust_capi/tests/capi.rs b/harfrust_capi/tests/capi.rs index d653332f..b513c597 100644 --- a/harfrust_capi/tests/capi.rs +++ b/harfrust_capi/tests/capi.rs @@ -28,6 +28,126 @@ const PLAIN_UPEM: c_uint = 1024; /// A variable font, for exercising variation settings. const VARIABLE_FONT: &str = "Linefont.ttf"; +#[test] +fn set_and_layout_lookup_queries() { + unsafe { + let set = hr_set_create(); + assert_eq!(hr_set_is_empty(set), 1); + hr_set_add(set, 32); + hr_set_add(set, HR_CODEPOINT_INVALID); + assert_eq!(hr_set_has(set, 32), 1); + assert_eq!(hr_set_has(set, HR_CODEPOINT_INVALID), 0); + hr_set_clear(set); + assert_eq!(hr_set_is_empty(set), 1); + + with_named_font("PT_Sans-Caption-Web-Regular.ttf", |face, _font| { + const GSUB: u32 = 0x4753_5542; + const GPOS: u32 = 0x4750_4F53; + assert_eq!(hr_ot_layout_has_substitution(face), 1); + assert_eq!(hr_ot_layout_has_positioning(face), 1); + let count = hr_ot_layout_table_get_lookup_count(face, GSUB); + assert!(count > 0); + assert!(hr_ot_layout_table_get_lookup_count(face, GPOS) > 0); + assert_eq!(hr_ot_layout_table_get_lookup_count(face, 0), 0); + hr_ot_layout_lookup_collect_glyphs(face, GSUB, count, set, set, set, set); + assert_eq!(hr_set_is_empty(set), 1); + hr_ot_layout_lookup_collect_glyphs(face, GSUB, 0, set, set, set, set); + assert_eq!(hr_set_is_empty(set), 0); + assert_eq!(hr_set_has(set, 5), 1); // quotedbl, an input to lookup 0 + assert_eq!(hr_set_has(set, 704), 1); // its substituted glyph + assert_eq!(hr_set_has(set, 3), 0); // space is outside this lookup + }); + hr_set_destroy(set); + } +} + +#[test] +fn contextual_lookup_collects_each_glyph_role() { + unsafe { + with_named_font("PT_Sans-Caption-Web-Regular.ttf", |face, _font| { + let sets = [ + hr_set_create(), + hr_set_create(), + hr_set_create(), + hr_set_create(), + ]; + hr_ot_layout_lookup_collect_glyphs( + face, + 0x4753_5542, + 2, + sets[0], + sets[1], + sets[2], + sets[3], + ); + assert_eq!(hr_set_has(sets[0], 36), 1); // backtrack A + assert_eq!(hr_set_has(sets[1], 311), 1); // contextual input + assert_eq!(hr_set_is_empty(sets[2]), 1); // no lookahead + assert_eq!(hr_set_has(sets[3], 609), 1); // nested substitution output + assert_eq!(hr_set_has(sets[0], 3), 0); // no all-glyph fallback + assert_eq!(hr_set_has(sets[1], 3), 0); + for set in sets { + hr_set_destroy(set); + } + }); + } +} + +#[test] +fn pair_class_lookup_collects_the_second_glyph_class() { + unsafe { + with_named_font("PT_Sans-Caption-Web-Regular.ttf", |face, _font| { + let input = hr_set_create(); + hr_ot_layout_lookup_collect_glyphs( + face, + 0x4750_4F53, + 2, + ptr::null_mut(), + input, + ptr::null_mut(), + ptr::null_mut(), + ); + assert_eq!(hr_set_has(input, 695), 1); + assert_eq!(hr_set_has(input, 7), 0); + hr_set_destroy(input); + }); + } +} + +#[test] +fn contextual_rule_and_class_formats_do_not_fall_back_to_all_glyphs() { + unsafe { + for (font_name, lookup) in [ + ("NotoSansSinhala.subset1.otf", 3), // sequence context format 1 + ("NotoSansMyanmarUI-Regular.subset1.otf", 3), // sequence context format 2 + ("NotoSansMyanmarUI-Regular.subset1.otf", 5), // chained context format 2 + ] { + with_named_font(font_name, |face, _font| { + let sets = [ + hr_set_create(), + hr_set_create(), + hr_set_create(), + hr_set_create(), + ]; + hr_ot_layout_lookup_collect_glyphs( + face, + 0x4753_5542, + lookup, + sets[0], + sets[1], + sets[2], + sets[3], + ); + assert_eq!(hr_set_has(sets[1], 1), 1, "{font_name} lookup {lookup}"); + for set in sets { + assert_eq!(hr_set_has(set, 0), 0, "{font_name} lookup {lookup}"); + hr_set_destroy(set); + } + }); + } + } +} + #[test] fn math_queries_scale_device_values_and_page() { unsafe { diff --git a/harfrust_capi/tests/compare_layout_hb.py b/harfrust_capi/tests/compare_layout_hb.py new file mode 100644 index 00000000..d2a834a5 --- /dev/null +++ b/harfrust_capi/tests/compare_layout_hb.py @@ -0,0 +1,100 @@ +"""Compare HarfRust layout glyph collection with HarfBuzz. + +Usage: python harfrust_capi/tests/compare_layout_hb.py HR_LIBRARY HB_LIBRARY FONT [FONT ...] +The libraries must be built for the host Python architecture. This is an +optional differential test; it does not add a HarfBuzz build dependency. +""" + +import argparse +import ctypes as c +from pathlib import Path + +TAGS = {"GSUB": 0x47535542, "GPOS": 0x47504F53} +KINDS = ("before", "input", "after", "output") +INVALID = 0xFFFFFFFF + + +def bind(library, prefix): + def function(name, result, *args): + fn = getattr(library, prefix + name) + fn.restype = result + fn.argtypes = args + return fn + + ptr = c.c_void_p + uint = c.c_uint + return { + "blob_file": function("blob_create_from_file", ptr, c.c_char_p), + "blob_destroy": function("blob_destroy", None, ptr), + "face_create": function("face_create", ptr, ptr, uint), + "face_destroy": function("face_destroy", None, ptr), + "set_create": function("set_create", ptr), + "set_destroy": function("set_destroy", None, ptr), + "set_has": function("set_has", c.c_int, ptr, uint), + "count": function("ot_layout_table_get_lookup_count", uint, ptr, uint), + "collect": function("ot_layout_lookup_collect_glyphs", None, ptr, uint, uint, ptr, ptr, ptr, ptr), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("harfrust", type=Path) + parser.add_argument("harfbuzz", type=Path) + parser.add_argument("fonts", nargs="+", type=Path) + args = parser.parse_args() + hr = bind(c.CDLL(str(args.harfrust.resolve())), "hr_") + hb_lib = c.CDLL(str(args.harfbuzz.resolve())) + hb = bind(hb_lib, "hb_") + hb_next = hb_lib.hb_set_next + hb_next.restype = c.c_int + hb_next.argtypes = [c.c_void_p, c.POINTER(c.c_uint)] + mismatches = 0 + checked = 0 + + for path in args.fonts: + blobs = [api["blob_file"](str(path.resolve()).encode()) for api in (hr, hb)] + faces = [api["face_create"](blob, 0) for api, blob in zip((hr, hb), blobs)] + try: + for name, tag in TAGS.items(): + counts = [api["count"](face, tag) for api, face in zip((hr, hb), faces)] + if counts[0] != counts[1]: + print(f"{path.name} {name}: lookup counts differ: {counts}") + mismatches += 1 + for index in range(min(counts)): + sets = [[api["set_create"]() for _ in KINDS] for api in (hr, hb)] + try: + for api, face, row in zip((hr, hb), faces, sets): + api["collect"](face, tag, index, *row) + checked += 1 + for kind, ours, reference in zip(KINDS, *sets): + missing = [] + value = c.c_uint(INVALID) + while hb_next(reference, c.byref(value)): + if not hr["set_has"](ours, value.value): + missing.append(value.value) + if len(missing) == 5: + break + extra = [] + for glyph in range(65536): + if hr["set_has"](ours, glyph) and not hb["set_has"](reference, glyph): + extra.append(glyph) + if len(extra) == 5: + break + if missing or extra: + print(f"{path.name} {name}[{index}] {kind}: missing={missing} extra={extra}") + mismatches += 1 + finally: + for api, row in zip((hr, hb), sets): + for item in row: + api["set_destroy"](item) + finally: + for api, face, blob in zip((hr, hb), faces, blobs): + api["face_destroy"](face) + api["blob_destroy"](blob) + + print(f"Compared {checked} lookups; {mismatches} set mismatches") + raise SystemExit(bool(mismatches)) + + +if __name__ == "__main__": + main()