nigig-org/crates/apps/pdf/pdf-graphics/tests/cff_port.rs
andodeki b1045d79d4
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): graphics/document port progress + android gates
- pdf-graphics: new cff, truetype, cjk_cmap, disk_cache, document_ai,
  path, reflow, text_diff modules + port tests (all green); syntax/type
  fixes for the aarch64 build (stray brace, &u8 derefs, f2dot14 assign).
- pdf-document: ocr, pdf_a, xmp modules + roundtrip tests; annotation
  editor updates.
- pdf-makepad: handle RenderCommand::SetOverprint (report via
  TransparencyError per ADR 0009, no silent drop); renderer/page_view
  updates for the new recording ops.
- matrix_client: nimanyatta native module (ungated; the aarch64 wrapper
  needs it unconditionally).
- pageflipnav home_screen: project_store sync is host-only until
  cad-ui's Widget Script derive is fixed for aarch64.

Verified: cargo check on all four crates clean at fork 66cc4f15f;
nigig-pdf-graphics tests pass; pageflipnav aarch64 release links.
2026-09-11 05:09:11 +03:00

453 lines
14 KiB
Rust

//! 1:1 port of `dart-pdf/packages/pdf_graphics/test/cff_test.dart`
//! (tranche 1: fixture-built font — structure, encoding, outlines, widths).
//!
//! `build_test_cff_font` is a line-for-line port of dart's
//! `buildTestCffFont()`: glyph 0 = .notdef, glyph 1 = an 800x800 square at
//! the origin mapped to code 65 ('A'), advance 660 via nominalWidthX 600 +
//! leading operand 60.
use nigig_pdf_graphics::cff::CffFont;
use nigig_pdf_graphics::path::PdfPathSegment;
fn u8(v: u8) -> Vec<u8> {
vec![v]
}
fn u16(v: u16) -> Vec<u8> {
vec![(v >> 8) as u8, (v & 0xff) as u8]
}
/// CFF DICT 5-byte integer (operator 29).
fn int5(v: i32) -> Vec<u8> {
vec![
29,
((v >> 24) & 0xff) as u8,
((v >> 16) & 0xff) as u8,
((v >> 8) & 0xff) as u8,
(v & 0xff) as u8,
]
}
fn join(parts: Vec<Vec<u8>>) -> Vec<u8> {
parts.into_iter().flatten().collect()
}
fn index(items: Vec<Vec<u8>>) -> Vec<u8> {
if items.is_empty() {
return u16(0);
}
let mut out = u16(items.len() as u16);
out.push(2); // offSize 2
let mut offset: u16 = 1;
out.extend(u16(offset));
for item in &items {
offset += item.len() as u16;
out.extend(u16(offset));
}
for item in items {
out.extend(item);
}
out
}
fn build_test_cff_font() -> Vec<u8> {
// charstrings: .notdef = endchar; square = 60 width, then contour.
let notdef = vec![14u8];
let square = vec![
139 + 60, // width delta 60 over nominalWidthX
139, 139, 21, // 0 0 rmoveto
249, 180, 6, // 800 hlineto
249, 180, 7, // 800 vlineto
253, 180, 6, // -800 hlineto
14, // endchar
];
let header = vec![1u8, 0, 4, 2];
let name_index = index(vec![b"Test".to_vec()]);
let string_index = u16(0);
let gsubr_index = u16(0);
// encoding: format 0, one code: 65 -> gid 1.
let encoding = vec![0u8, 1, 65];
let charstrings_index = index(vec![notdef, square]);
// private dict: defaultWidthX 500 (20), nominalWidthX 600 (21).
let private_dict = join(vec![int5(500), u8(20), int5(600), u8(21)]);
// top dict (fixed-size operands so offsets are computable):
// CharStrings (17), Encoding (16), Private (18).
let top_dict_with = |charstrings_at: i32, encoding_at: i32, private_at: i32| -> Vec<u8> {
join(vec![
int5(charstrings_at),
u8(17),
int5(encoding_at),
u8(16),
int5(private_dict.len() as i32),
int5(private_at),
u8(18),
])
};
let top_dict_size = top_dict_with(0, 0, 0).len();
let top_dict_index_size = index(vec![vec![0u8; top_dict_size]]).len();
let fixed_prefix =
header.len() + name_index.len() + top_dict_index_size + string_index.len() + gsubr_index.len();
let encoding_at = fixed_prefix;
let charstrings_at = encoding_at + encoding.len();
let private_at = charstrings_at + charstrings_index.len();
join(vec![
header,
name_index,
index(vec![top_dict_with(
charstrings_at as i32,
encoding_at as i32,
private_at as i32,
)]),
string_index,
gsubr_index,
encoding,
charstrings_index,
private_dict,
])
}
fn test_font() -> CffFont {
CffFont::parse(&build_test_cff_font()).expect("test CFF parses")
}
#[test]
fn parses_the_cff_structure() {
let font = test_font();
assert_eq!(font.num_glyphs(), 2);
assert!(!font.is_cid_keyed());
}
#[test]
fn encoding_maps_character_codes_to_glyphs() {
let font = test_font();
assert_eq!(font.gid_for_code(65), 1);
assert_eq!(font.gid_for_code(66), 0);
}
#[test]
fn charstrings_interpret_to_em_unit_outlines() {
let font = test_font();
let square = font.outline_for_glyph(1).expect("square outline");
let segs = square.segments();
let first = &segs[0];
assert!(
matches!(first, PdfPathSegment::MoveTo { x, y } if x.abs() < 1e-9 && y.abs() < 1e-9),
"first must be MoveTo(0,0), got {first:?}"
);
// 800 font units at the default 0.001 matrix = 0.8 em.
let lines: Vec<(f64, f64)> = segs
.iter()
.filter_map(|s| match s {
PdfPathSegment::LineTo { x, y } => Some((*x, *y)),
_ => None,
})
.collect();
assert_eq!(lines.len(), 3, "square has 3 lines, got {segs:?}");
assert!((lines[0].0 - 0.8).abs() < 1e-9, "got {lines:?}");
assert!((lines[1].1 - 0.8).abs() < 1e-9, "got {lines:?}");
assert!(
matches!(segs.last(), Some(PdfPathSegment::Close)),
"last must be Close, got {segs:?}"
);
}
#[test]
fn width_comes_from_the_leading_charstring_operand() {
let font = test_font();
font.outline_for_glyph(1);
// nominalWidthX 600 + operand 60 = 660 units = 0.66 em.
let advance = font.advance_for_glyph(1).expect("advance");
assert!((advance - 0.66).abs() < 1e-9, "got {advance}");
}
#[test]
fn empty_glyphs_return_no_outline_but_a_default_width() {
let font = test_font();
assert!(font.outline_for_glyph(0).is_none());
// .notdef has no width operand: defaultWidthX 500.
let advance = font.advance_for_glyph(0).expect("advance");
assert!((advance - 0.5).abs() < 1e-9, "got {advance}");
}
#[test]
fn garbage_input_parses_to_none() {
assert!(CffFont::parse(b"not a font").is_none());
}
// ---------- tranche 2: seac composition (corpus) + OpenType CFF ----------
use nigig_pdf_cos::{PdfDict, PdfObj};
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::cff::OpenTypeCffFont;
/// The dart-pdf checkout this port tracks; corpus PDFs are read from it.
/// Tests that need a corpus file skip (with a notice) when it is absent so
/// the suite stays green on machines without the checkout.
const DART_CORPUS: &str = "/Users/aok/Projects/rustdev/CratesCode/dart-pdf/test_corpora/pdfjs";
fn corpus(relative: &str) -> Option<Vec<u8>> {
let path = format!("{DART_CORPUS}/{relative}");
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(_) => {
eprintln!("SKIP (no dart-pdf checkout): {path}");
None
}
}
}
fn cff_from_resources(
doc: &mut PdfDocument,
resources: &PdfDict,
font_name: &str,
) -> Option<CffFont> {
let fonts = doc.resolve(resources.get("Font")?).ok()?;
let pdf_font = doc.resolve(fonts.as_dict()?.get(font_name)?).ok()?;
let descriptor = doc.resolve(pdf_font.as_dict()?.get("FontDescriptor")?).ok()?;
let font_file = descriptor.as_dict()?.get("FontFile3")?.clone();
let data = doc.resolve_stream(&font_file).ok()?;
CffFont::parse(&data)
}
fn page_resources(doc: &mut PdfDocument) -> Option<PdfDict> {
let page_ref = doc.page_object_ref(0)?;
let page_obj = doc.resolve_ref(page_ref).ok()?;
let resources = doc.resolve(page_obj.as_dict()?.get("Resources")?).ok()?;
resources.as_dict().cloned()
}
fn cff_from_page_font(path: &str, font_name: &str) -> Option<CffFont> {
let bytes = corpus(path)?;
let mut doc = PdfDocument::parse(&bytes).ok()?;
let resources = page_resources(&mut doc)?;
cff_from_resources(&mut doc, &resources, font_name)
}
fn cff_from_form_font(path: &str, form_name: &str, font_name: &str) -> Option<CffFont> {
let bytes = corpus(path)?;
let mut doc = PdfDocument::parse(&bytes).ok()?;
let resources = page_resources(&mut doc)?;
let xobjects = doc.resolve(resources.get("XObject")?).ok()?;
let form = doc.resolve(xobjects.as_dict()?.get(form_name)?).ok()?;
let form_resources = doc.resolve(form.as_stream()?.dict.get("Resources")?).ok()?;
cff_from_resources(&mut doc, form_resources.as_dict()?, font_name)
}
#[test]
fn endchar_seac_composes_accented_glyphs() {
let Some(cff) = cff_from_form_font("endchar.pdf", "Fm0", "T1_0") else {
return;
};
let e = cff.outline_for_glyph(cff.gid_for_name("E")).expect("E");
let acute = cff
.outline_for_glyph(cff.gid_for_name("acute"))
.expect("acute");
let eacute = cff
.outline_for_glyph(cff.gid_for_name("Eacute"))
.expect("Eacute");
assert!(
eacute.segments().len() > e.segments().len() + acute.segments().len() - 2,
"Eacute should compose E + acute"
);
}
#[test]
fn endchar_seac_composes_tilde_accents() {
let Some(cff) = cff_from_page_font("glyph_accent.pdf", "F1") else {
return;
};
let a = cff.outline_for_glyph(cff.gid_for_name("a")).expect("a");
let tilde = cff
.outline_for_glyph(cff.gid_for_name("tilde"))
.expect("tilde");
let atilde = cff
.outline_for_glyph(cff.gid_for_name("atilde"))
.expect("atilde");
assert!(
atilde.segments().len() > a.segments().len() + tilde.segments().len() - 2,
"atilde should compose a + tilde"
);
}
// --- OpenType CFF container builders (port of _testOpenTypeCff etc.) ---
fn u16be(v: u16) -> [u8; 2] {
[(v >> 8) as u8, (v & 0xff) as u8]
}
fn u32be(v: u32) -> [u8; 4] {
[
(v >> 24) as u8,
((v >> 16) & 0xff) as u8,
((v >> 8) & 0xff) as u8,
(v & 0xff) as u8,
]
}
fn flat(parts: Vec<&[u8]>) -> Vec<u8> {
parts.concat()
}
/// Port of dart `_testCmaps`: one subtable per Unicode cmap format.
fn test_cmaps() -> Vec<Vec<u8>> {
let mut format0 = flat(vec![&u16be(0), &u16be(262), &u16be(0)]);
let mut glyphs = vec![0u8; 256];
glyphs[0x41] = 1;
format0.extend(glyphs);
// format 4: two segments; 0x42 maps through glyphIdArray[0] = 2 with
// idDelta -1, yielding glyph 1.
let format4 = flat(vec![
&u16be(4),
&u16be(34),
&u16be(0),
&u16be(4),
&u16be(4),
&u16be(1),
&u16be(0),
&u16be(0x42),
&u16be(0xffff),
&u16be(0),
&u16be(0x42),
&u16be(0xffff),
&u16be(0xffff),
&u16be(1),
&u16be(4),
&u16be(0),
&u16be(2),
]);
let format6 = flat(vec![
&u16be(6),
&u16be(12),
&u16be(0),
&u16be(0x400),
&u16be(1),
&u16be(1),
]);
let format12 = flat(vec![
&u16be(12),
&u16be(0),
&u32be(28),
&u32be(0),
&u32be(1),
&u32be(0x1f600),
&u32be(0x1f600),
&u32be(1),
]);
vec![format0, format4, format6, format12]
}
fn cmap_table(subtables: &[Vec<u8>]) -> Vec<u8> {
let records_len = 4 + subtables.len() * 8;
let mut offset = records_len;
let mut out = flat(vec![&u16be(0), &u16be(subtables.len() as u16)]);
for (index, sub) in subtables.iter().enumerate() {
out.extend(u16be(0));
out.extend(u16be(index as u16));
out.extend(u32be(offset as u32));
offset += sub.len();
}
for sub in subtables {
out.extend(sub);
}
out
}
fn test_opentype_cff(face_offset: usize, cmaps: Option<Vec<Vec<u8>>>) -> Vec<u8> {
let cff = build_test_cff_font();
let cmap = cmap_table(&cmaps.unwrap_or_else(test_cmaps));
const DIR_LEN: usize = 12 + 2 * 16;
let cff_offset = face_offset + DIR_LEN;
let cmap_offset = cff_offset + cff.len();
let mut out = flat(vec![
&u32be(0x4f54544f), // OTTO
&u16be(2),
&u16be(0),
&u16be(0),
&u16be(0),
b"CFF ",
&u32be(0),
&u32be(cff_offset as u32),
&u32be(cff.len() as u32),
b"cmap",
&u32be(0),
&u32be(cmap_offset as u32),
&u32be(cmap.len() as u32),
]);
out.extend(cff);
out.extend(cmap);
out
}
fn test_opentype_cff_collection() -> Vec<u8> {
const FIRST_OFFSET: usize = 20;
let first = test_opentype_cff(FIRST_OFFSET, None);
let second_offset = FIRST_OFFSET + first.len();
let second = test_opentype_cff(second_offset, None);
let mut out = flat(vec![
b"ttcf",
&u32be(0x00010000),
&u32be(2),
&u32be(FIRST_OFFSET as u32),
&u32be(second_offset as u32),
]);
out.extend(first);
out.extend(second);
out
}
#[test]
fn opentype_cff_reads_unicode_cmap_formats_and_collections() {
let bytes = test_opentype_cff(0, None);
let face = OpenTypeCffFont::parse(&bytes, 0).expect("OT CFF face");
assert_eq!(face.num_glyphs(), 2);
assert_eq!(face.gid_for_unicode(0x41), 1, "format 0");
assert_eq!(face.gid_for_unicode(0x42), 1, "format 4");
assert_eq!(face.gid_for_unicode(0x400), 1, "format 6");
assert_eq!(face.gid_for_unicode(0x1f600), 1, "format 12");
assert_eq!(face.gid_for_unicode(0x43), 0);
assert!(face.outline_for_glyph(1).is_some());
let advance = face.advance_for_glyph(1).expect("advance");
assert!((advance - 0.66).abs() < 1e-9, "got {advance}");
let collection = test_opentype_cff_collection();
assert!(OpenTypeCffFont::parse(&collection, 1).is_some());
assert!(OpenTypeCffFont::parse(&collection, -1).is_none());
assert!(OpenTypeCffFont::parse(&collection, 2).is_none());
}
#[test]
fn opentype_cff_rejects_malformed_or_incomplete_containers() {
let plain = test_opentype_cff(0, None);
assert!(OpenTypeCffFont::parse(&plain, 1).is_none());
assert!(OpenTypeCffFont::parse(b"not an sfnt", 0).is_none());
let mut truetype = plain.clone();
truetype[0..4].copy_from_slice(&[0, 1, 0, 0]);
assert!(OpenTypeCffFont::parse(&truetype, 0).is_none());
assert!(OpenTypeCffFont::parse(&test_opentype_cff(0, Some(vec![])), 0).is_none());
}
#[test]
fn opentype_cff_system_face_keeps_unicode_cmap() {
// Port of the macOS-only dart test; runs only when the Hiragino face is
// present, otherwise skips with a notice (no system-font dependency).
let path = "/System/Library/Fonts/ヒラギノ明朝 ProN.ttc";
let Ok(bytes) = std::fs::read(path) else {
eprintln!("SKIP (no system face): {path}");
return;
};
let face = OpenTypeCffFont::parse(&bytes, 0).expect("system face");
let glyph = face.gid_for_unicode('日' as u32);
assert!(glyph > 0);
assert!(face.outline_for_glyph(glyph).is_some());
assert!(face.advance_for_glyph(glyph).unwrap_or(0.0) > 0.0);
}