//! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance. //! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3 //! mixed-run fixture. Broad complex typography still needs independent visual validation. #![cfg(feature = "synth")] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod support { pub mod corpus; pub mod text_fixture; } use support::text_fixture::*; use vectorcraft_affinity::{ Document, Limits, model::{Align, Kind, Text}, paint::{Color, Paint}, synth::{F, tag}, }; fn read(blocks: Vec) -> Document { vectorcraft_affinity::read(&document(blocks, false, 96.0, IDENTITY), Limits::default()).unwrap() } fn text(d: &Document) -> &Text { match &d.spreads[0].nodes[0].kind { Kind::Text(t) => t, other => panic!("expected text, got {other:?}"), } } fn content(t: &Text) -> String { t.runs.iter().map(|r| r.text.as_str()).collect() } fn warning(d: &Document, needle: &str) -> bool { d.warnings.iter().any(|w| w.contains(needle)) } #[test] fn mixed_fonts_keep_unicode_and_attribute_boundaries() { let d = read(vec![block( utf8("AĆ©šŸ˜€e\u{301}××‘ę—„ęœ¬\u{2028}x\u{2029}\0"), vec![ run(3, Some(attrs("Family One", "FamilyOne-Regular", 400, false, 16.0))), run(9, Some(attrs("Family Two", "FamilyTwo-BoldItalic", 700, true, 24.0))), run(13, Some(attrs("Family Three", "FamilyThree-Light", 300, false, 20.0))), ], &[1], )]); let t = text(&d); assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::>(), ["AĆ©šŸ˜€", "e\u{301}××‘ę—„ęœ¬", "\u{2028}x\u{2029}"]); assert_eq!(content(t), "AĆ©šŸ˜€e\u{301}××‘ę—„ęœ¬\u{2028}x\u{2029}"); assert_eq!( (t.runs[1].family.as_str(), t.runs[1].postscript.as_str(), t.runs[1].weight, t.runs[1].italic), ("Family Two", "FamilyTwo-BoldItalic", 700, true) ); assert_eq!((t.runs[1].size, t.runs[1].tracking, t.runs[1].leading), (24.0, 0.025, Some(30.0))); assert!(matches!(t.runs[1].fill, Paint::Solid(Color::Rgb { a: 1.0, .. }))); assert_eq!(t.align, Align::Center); assert_eq!((t.anchor.x, t.anchor.y), (110.0, 38.0)); assert!(d.warnings.is_empty(), "{:?}", d.warnings); } #[test] fn missing_attributes_and_uncovered_tail_preserve_characters() { for runs in [vec![], vec![run(2, None)], vec![run(2, Some(attrs("First", "", 400, false, 14.0)))]] { let d = read(vec![block(utf8("Ć©šŸ˜€ tail\0"), runs, &[])]); assert_eq!(content(text(&d)), "Ć©šŸ˜€ tail"); assert!(warning(&d, "text without character attributes")); assert!(matches!(text(&d).runs.last().unwrap().fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }))); assert_eq!(text(&d).runs.last().unwrap().size, 16.0); // Recovery default: 12 pt at 96 dpi. } } #[test] fn malformed_ranges_do_not_discard_the_story() { let mut missing = run(1, None); missing.clear(); let d = read(vec![block( utf8("abcdef"), vec![missing, run(-1, None), run(3, Some(attrs("First", "", 400, false, 10.0))), run(2, None), run(i32::MAX, None)], &[], )]); assert_eq!(content(text(&d)), "abcdef"); assert_eq!(text(&d).runs[0].text, "abc"); assert!(warning(&d, "invalid text attribute range")); } #[test] fn inline_fields_count_for_ranges_but_are_reported_and_omitted() { let glyphs = F::Obj( tag(b"GStr"), vec![( tag(b"Mixd"), F::Objs( tag(b"MixS"), vec![ vec![(tag(b"Glys"), F::Shared(vec![F::Obj(tag(b"FldN"), vec![])])), (tag(b"Utf8"), F::Str("Ć©šŸ˜€".into()))], vec![(tag(b"Utf8"), F::Str("å°¾\0".into()))], ], ), )], ); let d = read(vec![block(glyphs, vec![run(3, Some(attrs("First", "", 400, false, 10.0))), run(5, Some(attrs("Second", "", 700, false, 15.0)))], &[])]); assert_eq!(text(&d).runs.iter().map(|r| r.text.as_str()).collect::>(), ["Ć©šŸ˜€", "å°¾"]); assert!(warning(&d, "text fields such as page numbers")); } #[test] fn mixed_paragraph_alignments_are_explicitly_reported() { let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]); assert_eq!(content(text(&d)), "one\u{2029}two"); assert_eq!(text(&d).align, Align::Right); assert!(warning(&d, "mixed paragraph alignments")); } #[test] fn unknown_alignment_and_invalid_font_weight_have_honest_fallbacks() { let d = read(vec![block(utf8("test"), vec![run(4, Some(attrs("", "OnlyPostScript", -50, true, f64::NAN)))], &[99])]); assert_eq!((text(&d).runs[0].weight, text(&d).runs[0].size), (400, 16.0)); assert_eq!(text(&d).align, Align::Left); assert!(warning(&d, "invalid font weight")); assert!(warning(&d, "unknown paragraph alignment")); } #[test] fn unreadable_block_does_not_remove_later_readable_text() { let d = read(vec![F::Obj(tag(b"StBl"), vec![]), block(utf8("still here"), vec![], &[])]); assert_eq!(content(text(&d)), "still here"); assert!(warning(&d, "a text block without readable characters")); } #[test] fn text_budget_preserves_prefix_and_reports_truncation() { let d = read(vec![block(utf8(&"Ć©".repeat((1 << 20) + 1)), vec![], &[]), block(utf8("later"), vec![], &[])]); assert_eq!(content(text(&d)).chars().count(), 1 << 20); assert!(content(text(&d)).chars().all(|c| c == 'Ć©')); assert!(warning(&d, "text longer than a million characters")); } #[test] fn boolean_opentype_and_observed_all_caps_selector_are_kept() { let mut a = attrs("Family", "", 400, false, 12.0); let settings = F::Obj( tag(b"OtAt"), vec![( tag(b"Setn"), F::Objs( tag(b"OTFS"), vec![ vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))], vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))], vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))], vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"salt"))), (tag(b"Valu"), F::I32(2))], ], ), )], ); if let F::Obj(_, fields) = &mut a { let objects = fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap(); objects.1 = F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]); } let d = read(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])]); assert_eq!(text(&d).runs[0].features, ["smcp", "-liga"]); assert!(text(&d).runs[0].all_caps); assert_eq!(content(text(&d)), "Mixed"); assert!(warning(&d, "non-boolean OpenType settings")); } #[test] fn stored_fallback_font_is_used_only_when_it_names_a_font() { for (family, expected) in [("", "Requested"), ("Emoji Family", "Emoji Family")] { let mut a = attrs("Requested", "RequestedPS", 700, true, 12.0); if let F::Obj(_, fields) = &mut a { fields.push((tag(b"RFnt"), F::Obj(tag(b"Font"), vec![(tag(b"Famy"), F::Str(family.into())), (tag(b"Wegt"), F::I32(400))]))); } let d = read(vec![block(utf8("šŸ˜€"), vec![run(1, Some(a))], &[])]); assert_eq!(text(&d).runs[0].family, expected); assert_eq!(warning(&d, "stored fallback font"), !family.is_empty()); } } #[test] fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() { let Some(corpus) = support::corpus::pinned() else { return }; // Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser // source was read; assertions describe this crate's parsing of its published MIT documents. let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap(); let d = load("patchy-text-runs.af"); let t = text(&d); assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::>(), ["AB", "cd", "Ɖ", "šŸ˜€", "X"]); assert_eq!(t.runs[3].family, "Segoe UI Emoji"); assert_eq!(t.runs[4].family, "Courier New"); assert!(warning(&d, "stored fallback font")); let d = load("patchy-text-caps.af"); let texts: Vec<_> = d.spreads[0].nodes.iter().filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None }).collect(); assert_eq!(texts.len(), 2); assert!(texts[0].runs[0].all_caps); assert_eq!(texts[1].runs[0].features, ["smcp"]); for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] { let d = load(name); assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings); } } proptest::proptest! { #![proptest_config(proptest::test_runner::Config::with_cases(64))] #[test] fn arbitrary_unicode_survives_damaged_attribute_ranges( chars in proptest::collection::vec(proptest::prelude::any::(), 0..128), ends in proptest::collection::vec(-10i32..200, 0..32), ) { let original: String = chars.into_iter().collect(); let expected: String = original.chars().filter(|c| *c != '\0' && *c != '\u{FFFC}').collect(); let d = read(vec![block(utf8(&original), ends.into_iter().map(|end| run(end, None)).collect(), &[])]); proptest::prop_assert_eq!(content(text(&d)), expected); } } #[test] fn partially_missing_attributes_use_visible_fill_but_explicit_no_fill_is_kept() { let empty = F::Obj(tag(b"GAtt"), vec![]); let none = F::Obj(tag(b"GAtt"), vec![(tag(b"Objs"), F::Shared(vec![F::Obj(tag(b"FilN"), vec![])]))]); let d = read(vec![block(utf8("ab"), vec![run(1, Some(empty)), run(2, Some(none))], &[])]); assert!(matches!(text(&d).runs[0].fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }))); assert_eq!(text(&d).runs[1].fill, Paint::None); assert_eq!(content(text(&d)), "ab"); assert!(warning(&d, "without readable fill attributes")); } #[test] fn excessive_feature_settings_are_bounded_and_reported() { let mut a = attrs("Family", "", 400, false, 12.0); if let F::Obj(_, fields) = &mut a { let settings = F::Obj( tag(b"OtAt"), vec![( tag(b"Setn"), F::Objs( tag(b"OTFS"), (0..257).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(), ), )], ); fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 = F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]); } let d = read(vec![block(utf8("x"), vec![run(1, Some(a))], &[])]); assert_eq!(text(&d).runs[0].features.len(), 256); assert!(warning(&d, "more than 256 text feature settings")); } #[test] fn shared_font_metadata_amplification_is_bounded_without_losing_characters() { let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0); if let F::Obj(_, fields) = &mut a { let settings = F::Obj( tag(b"OtAt"), vec![( tag(b"Setn"), F::Objs( tag(b"OTFS"), (0..256).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(), ), )], ); fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 = F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]); } let F::Obj(class, fields) = a else { panic!("attributes must be an object") }; let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))]; runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42))))); let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]); let t = text(&d); assert_eq!(content(t), "x".repeat(1000)); assert_eq!(t.runs.len(), 1000); let metadata: usize = t .runs .iter() .flat_map(|r| [&r.family, &r.postscript].into_iter().chain(r.features.iter())) .filter(|s| !s.is_empty()) .map(|s| s.len() + std::mem::size_of::()) .sum(); assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes"); assert_eq!(t.runs[0].family.len(), 16 * 1024); assert_eq!(t.runs[0].features.len(), 256); assert!(t.runs.last().unwrap().family.is_empty()); assert!(t.runs.last().unwrap().features.is_empty()); assert!(warning(&d, "text attribute budget exceeded")); } #[test] fn shared_gradient_attributes_are_bounded_without_losing_characters() { let color = F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]); let mut colors = vec![color]; colors.extend((1..1024).map(|_| F::Ref(43))); let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect(); let gradient = F::Obj( tag(b"FilG"), vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))], ); let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![gradient]))]); let mut runs = vec![run(1, Some(attrs))]; runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42))))); let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]); let t = text(&d); assert_eq!(content(t), "x".repeat(1000)); assert_eq!(t.runs.len(), 1000); let stop_count: usize = t .runs .iter() .map(|r| match &r.fill { Paint::Gradient(g) => g.stops.len(), _ => 0, }) .sum(); assert!(stop_count > 0); assert!(stop_count * std::mem::size_of::() <= 16 << 20); let Paint::Gradient(first) = &t.runs[0].fill else { panic!("first gradient must fit") }; assert_eq!(first.stops.len(), 1024); assert!(matches!(t.runs.last().unwrap().fill, Paint::Solid(_))); assert!(warning(&d, "text attribute budget exceeded")); assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent"); }