Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions harfrust_capi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ callback with `hr_face_create_for_tables`, which asks for one table at a time.
Use `hr_face_set_get_table_tags_func` to enumerate the tables of a callback
face; blob faces enumerate their SFNT directory automatically.
`hr_face_set_index` changes the reported index without selecting new tables.
`hr_face_collect_unicodes` adds the selected cmap subtable's coverage to a set.

`hr_shape` already reuses shape plans through a per-face cache, the way
`hb_shape` does internally, so reach for `hr_shape_plan_create` only when you
Expand Down
1 change: 1 addition & 0 deletions harfrust_capi/include/hr-hb.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
#define hb_direction_from_string hr_direction_from_string
#define hb_direction_t hr_direction_t
#define hb_direction_to_string hr_direction_to_string
#define hb_face_collect_unicodes hr_face_collect_unicodes
#define hb_face_count hr_face_count
#define hb_face_create hr_face_create
#define hb_face_create_for_tables hr_face_create_for_tables
Expand Down
12 changes: 12 additions & 0 deletions harfrust_capi/include/hr.h
Original file line number Diff line number Diff line change
Expand Up @@ -3704,6 +3704,18 @@ struct hr_blob_t *hr_face_reference_blob(struct hr_face_t *face);
*/
struct hr_blob_t *hr_face_reference_table(struct hr_face_t *face, hr_tag_t tag);

/**
* Adds the face's nominal cmap coverage to `out`, preserving existing entries.
*
* Uses the selected cmap subtable's stored character codes, without the symbol
* fallback or Mac Roman conversion applied by nominal glyph lookup. Variation
* sequences are not included. A missing cmap adds nothing.
* # Safety
* `face` must be null or live; `out` must be null or live and must not be
* accessed concurrently while it is being modified.
*/
void hr_face_collect_unicodes(struct hr_face_t *face, struct hr_set_t *out);

/**
* Creates a font over a face.
*
Expand Down
58 changes: 57 additions & 1 deletion harfrust_capi/src/face.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ use std::sync::{Arc, OnceLock};

use harfrust::font::{Blob, Font, TableFunction};
use harfrust::Tag;
use read_fonts::{model::Format, TableProvider};
use read_fonts::{
model::Format,
tables::cmap::{CmapIterLimits, CmapSubtable},
TableProvider,
};

use crate::blob::hr_blob_t;
use crate::common::{hr_bool_t, hr_tag_t, tag_from_rust, tag_to_rust};
use crate::object::{self, hr_destroy_func_t, hr_user_data_key_t, Empty, Object, ObjectHeader};
use crate::plan::PlanCache;
use crate::set::hr_set_t;

/// Callback returning the data for one table of a face.
///
Expand Down Expand Up @@ -565,3 +570,54 @@ pub unsafe extern "C" fn hr_face_reference_table(
}
}
}

/// Adds the face's nominal cmap coverage to `out`, preserving existing entries.
///
/// Uses the selected cmap subtable's stored character codes, without the symbol
/// fallback or Mac Roman conversion applied by nominal glyph lookup. Variation
/// sequences are not included. A missing cmap adds nothing.
/// # Safety
/// `face` must be null or live; `out` must be null or live and must not be
/// accessed concurrently while it is being modified.
#[no_mangle]
pub unsafe extern "C" fn hr_face_collect_unicodes(face: *mut hr_face_t, out: *mut hr_set_t) {
let face = unsafe { object::or_empty(face.cast_const()) };
let Some(font) = face.font() else { return };
let Some((_, _, subtable)) = font
.tables()
.cmap()
.ok()
.and_then(|table| table.best_subtable())
else {
return;
};
let Some(out) = (unsafe { object::as_mutable(out) }) else {
return;
};
// A format 13 group maps an entire range to one glyph. Insert ranges
// directly, both to bound malformed glyph IDs and to avoid iterating
// over a million characters in a last-resort font.
if let CmapSubtable::Format13(table) = subtable {
for group in table.groups() {
let first = group.start_char_code();
let last = group.end_char_code().min(0x0010_FFFF);
let glyph = group.glyph_id();
if first <= last && glyph != 0 && glyph < font.num_glyphs() {
out.values.insert_range(first..=last);
}
}
} else {
let limits = CmapIterLimits {
// The iterator's character limit is exclusive.
max_char: 0x0011_0000,
glyph_count: font.num_glyphs(),
};
let is_format4 = matches!(subtable, CmapSubtable::Format4(_));
for (codepoint, glyph) in subtable.iter_with_limits(limits) {
// Format 4's final U+FFFF segment is a sentinel.
if glyph.to_u32() != 0 && !(is_format4 && codepoint == 0xFFFF) {
out.add(codepoint);
}
}
}
}
50 changes: 50 additions & 0 deletions harfrust_capi/tests/skia_face.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,53 @@ fn default_unicode_scripts_match_itemization_needs() {
assert_eq!(hr_unicode_script(ptr::null_mut(), codepoint), script);
}
}

#[test]
fn unicode_collection_adds_coverage_without_clearing_the_set() {
unsafe {
let face = face();
let set = hr_set_create();
hr_set_add(set, 0x0010_FFFF);
hr_face_collect_unicodes(face, set);
for codepoint in ['A', 'a', 'b'] {
assert_eq!(hr_set_has(set, codepoint as u32), 1);
}
assert_eq!(hr_set_has(set, 0x0010_FFFF), 1);
let population = hr_set_get_population(set);
assert!(population > 3);
hr_face_collect_unicodes(face, set);
hr_face_collect_unicodes(ptr::null_mut(), set);
hr_face_collect_unicodes(face, ptr::null_mut());
hr_face_collect_unicodes(face, hr_set_get_empty());
assert_eq!(hr_set_get_population(set), population);
hr_set_invert(set);
hr_set_del(set, 'a' as u32);
hr_face_collect_unicodes(face, set);
assert_eq!(hr_set_has(set, 'a' as u32), 1);
hr_set_destroy(set);
hr_face_destroy(face);
}
}

#[test]
fn unicode_collection_covers_constant_format13_ranges() {
unsafe {
let data = include_bytes!("../../harfrust/tests/fonts/in-house/AdobeBlank2.ttf");
let blob = hr_blob_create(
data.as_ptr().cast(),
data.len() as u32,
HR_MEMORY_MODE_READONLY,
ptr::null_mut(),
None,
);
let face = hr_face_create(blob, 0);
hr_blob_destroy(blob);
let set = hr_set_create();
hr_face_collect_unicodes(face, set);
assert_eq!(hr_set_get_population(set), 1_111_998);
assert_eq!(hr_set_has(set, 0), 1);
assert_eq!(hr_set_has(set, 0x0010_FFFD), 1);
hr_set_destroy(set);
hr_face_destroy(face);
}
}
124 changes: 124 additions & 0 deletions scripts/test-capi-coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Compare nominal cmap coverage with HarfBuzz.

Build harfrust_capi, then run:
python3 scripts/test-capi-coverage.py --library /path/to/libharfrust_c.so \
--harfbuzz /path/to/libharfbuzz.so --fonts harfrust/tests/fonts

HarfBuzz 14.5.1 incorrectly truncates format-13 constant-glyph ranges as though
glyph IDs incremented within each range. For those subtables, independently
check HarfRust's complete ranges and report the HarfBuzz difference separately.
"""
import argparse
import ctypes as C
from pathlib import Path
import struct

U, P = C.c_uint, C.c_void_p
SEED = 0x10FFFF


class Api:
def __init__(self, library, prefix):
self.library = C.CDLL(library)

def bind(name, result, arguments):
function = getattr(self.library, prefix + name)
function.restype = result
function.argtypes = arguments
return function

self.blob = bind("blob_create", P, [P, U, C.c_int, P, P])
self.drop_blob = bind("blob_destroy", None, [P])
self.blob_data = bind("blob_get_data", P, [P, C.POINTER(U)])
self.face = bind("face_create", P, [P, U])
self.face_count = bind("face_count", U, [P])
self.drop_face = bind("face_destroy", None, [P])
self.table = bind("face_reference_table", P, [P, U])
self.glyph_count = bind("face_get_glyph_count", U, [P])
self.make_set = bind("set_create", P, [])
self.drop_set = bind("set_destroy", None, [P])
self.add = bind("set_add", None, [P, U])
self.collect = bind("face_collect_unicodes", None, [P, P])
self.next = bind("set_next", C.c_int, [P, C.POINTER(U)])

def coverage(self, face):
output = self.make_set()
self.add(output, SEED)
self.collect(face, output)
value, values = U(0xFFFFFFFF), set()
while self.next(output, C.byref(value)):
values.add(value.value)
self.drop_set(output)
return values

def format13_coverage(self, face):
blob = self.table(face, int.from_bytes(b"cmap", "big"))
size = U()
pointer = self.blob_data(blob, C.byref(size))
data = C.string_at(pointer, size.value) if size.value else b""
self.drop_blob(blob)
if len(data) < 4:
return None
count = struct.unpack_from(">H", data, 2)[0]
records = [struct.unpack_from(">HHI", data, 4 + i * 8) for i in range(count)]
priority = [(3, 0), (3, 10), (0, 6), (0, 4), (3, 1),
(0, 3), (0, 2), (0, 1), (0, 0), (1, 0)]
for platform, encoding in priority:
for record_platform, record_encoding, offset in records:
if (platform, encoding) != (record_platform, record_encoding):
continue
format_id = struct.unpack_from(">H", data, offset)[0]
if format_id not in (0, 4, 6, 10, 12, 13):
continue
if format_id != 13:
return None
groups = struct.unpack_from(">I", data, offset + 12)[0]
glyph_count = self.glyph_count(face)
result = {SEED}
for i in range(groups):
first, last, glyph = struct.unpack_from(">III", data, offset + 16 + i * 12)
if 0 < glyph < glyph_count:
result.update(range(first, min(last, 0x10FFFF) + 1))
return result
return None


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--library", required=True)
parser.add_argument("--harfbuzz", required=True)
parser.add_argument("--fonts", type=Path, required=True)
args = parser.parse_args()
hr, hb = Api(args.library, "hr_"), Api(args.harfbuzz, "hb_")
matched, corrected = 0, 0
for path in sorted(args.fonts.rglob("*")):
if path.suffix.lower() not in {".ttf", ".otf", ".ttc"}:
continue
data = path.read_bytes()
hr_blob = hr.blob(data, len(data), 0, None, None)
hb_blob = hb.blob(data, len(data), 0, None, None)
for index in range(hb.face_count(hb_blob)):
hr_face, hb_face = hr.face(hr_blob, index), hb.face(hb_blob, index)
try:
actual, expected = hr.coverage(hr_face), hb.coverage(hb_face)
if actual == expected:
matched += 1
else:
complete = hr.format13_coverage(hr_face)
assert complete is not None and actual == complete and expected <= actual, (
path, index, len(actual), len(expected), sorted(actual ^ expected)[:10])
print(f"Complete format-13 coverage: {path} face {index}; "
f"HarfRust {len(actual)} entries, HarfBuzz {len(expected)}")
corrected += 1
finally:
hr.drop_face(hr_face)
hb.drop_face(hb_face)
hr.drop_blob(hr_blob)
hb.drop_blob(hb_blob)
print(f"Matched {matched} coverage sets; independently checked {corrected} "
"complete format-13 coverage sets (counts include a seeded existing entry).")


if __name__ == "__main__":
main()
Loading