use super::*; /// Character Direction is saved only when set, and styles written before it existed read as /// Default. #[test] fn character_direction_is_saved_only_when_set() { let st = CharStyle::default(); assert_eq!(st.char_direction, None); assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); } let old = serde_json::to_value(&st).unwrap(); assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); } /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. #[test] fn ruby_is_saved_and_missing_ruby_reads_as_none() { let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; let json = serde_json::to_string(&t).unwrap(); assert!(json.contains("かんじ")); let back: TextObject = serde_json::from_str(&json).unwrap(); assert_eq!(back.ruby, t.ruby); assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); // Old files have no `ruby` key: none. let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); old.as_object_mut().unwrap().remove("ruby"); assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); // A damaged file's range past the text and its empty reading are clamped away. let mut damaged = t.clone(); damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; damaged.normalize_ruby(); assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); } /// Digits map every digit of the three sets into the chosen one, leave the rest alone, and /// are saved only when set. #[test] fn digits_map_between_the_numeral_systems() { let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { assert_eq!(map(Digits::Default, s), s); assert_eq!(map(Digits::Arabic, s), "0123456789"); assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩"); assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹"); } // Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets. assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔"); let st = CharStyle::default(); assert!(serde_json::to_value(&st).unwrap().get("digits").is_none()); let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() }; assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi"); // Styles written before the attribute existed (no `digits` key) read as Default. let old = serde_json::to_value(&st).unwrap(); assert_eq!(serde_json::from_value::(old).unwrap().digits, Digits::Default); assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); } /// Language is saved only when set; styles written before it existed read as the default. #[test] fn language_is_saved_only_when_set() { let st = CharStyle::default(); let old = serde_json::to_value(&st).unwrap(); assert!(old.get("language").is_none()); assert_eq!(serde_json::from_value::(old).unwrap().language, None); let turkish = CharStyle { language: Some("tr".into()), ..CharStyle::default() }; assert_eq!(serde_json::to_value(&turkish).unwrap()["language"], "tr"); assert_eq!(serde_json::from_value::(serde_json::to_value(&turkish).unwrap()).unwrap(), turkish); } /// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. #[test] fn paragraph_effective_leading_follows_auto_leading() { let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() }; let mut para = ParaStyle::default(); assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%"); para.justification.auto_leading = 200.0; assert_eq!(para.effective_leading(&st), 20.0); para.justification.auto_leading = 0.0; assert_eq!(para.effective_leading(&st), 0.0); let set = CharStyle { leading: Some(14.0), ..st.clone() }; assert_eq!(para.effective_leading(&set), 14.0); para.justification.auto_leading = f64::NAN; assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number"); } /// A file can't feed layout justification values `text.setFormat` would refuse: each is /// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and /// Maximum out of order go back to their defaults. #[test] fn justification_options_read_from_a_file_stay_in_range() { let d = JustificationOptions::default(); let read: ParaStyle = serde_json::from_value(serde_json::json!({ "justification": { "word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0], "glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12 } })) .unwrap(); let j = read.justification; assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]); assert_eq!(j.letter_spacing, d.letter_spacing, "out of order"); assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]); assert_eq!(j.auto_leading, 0.0); let mut j = JustificationOptions { word_spacing: [f64::NAN, 100.0, f64::INFINITY], letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY], auto_leading: f64::NAN, ..d.clone() }; j.keep_in_range(); assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading)); let mut same = d.clone(); same.keep_in_range(); assert_eq!(same, d); } /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and /// starts again after a paragraph in no list. A list from before number styles reads as /// bulleted, and a bulleted list saves no number style. #[test] fn number_styles_write_their_numbers_and_roll_over() { let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::>(); assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]); assert_eq!( numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]), ["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"] ); assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]); assert_eq!( numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]), ["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"] ); assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]); assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]); assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]); assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); let mut count = 0; let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] .iter() .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) .collect(); assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); assert_eq!(count, 1); let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); assert_eq!( serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(), serde_json::json!({"bullet": "dot", "number": "lowerRoman"}) ); } /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph /// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the /// filled dot. Other text, the same characters later in a paragraph or without the space, and /// a paragraph already in a list start nothing. #[test] fn typed_list_starts_are_found_at_a_paragraphs_start() { let (arabic, letters, dot) = (ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot)); let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed); assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some)); for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] { assert_eq!(ParaList::typed(other), None, "{other:?}"); } let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default()); assert_eq!(t.typed_list_at(9), Some((6, arabic))); assert_eq!(t.typed_list_at(19), Some((16, letters))); assert_eq!(t.typed_list_at(27), Some((25, dot))); // Not at the paragraph's start, not right after the space, or past the end. for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] { assert_eq!(t.typed_list_at(at), None, "{at}"); } let mut styles = t.paragraph_styles(); styles[1].list = Some(ParaList::bulleted(Bullet::Square)); t.set_paragraph_styles(styles); assert_eq!(t.typed_list_at(9), None, "a list paragraph stays as it is"); assert_eq!(t.typed_list_at(19), Some((16, letters))); } #[test] fn character_controls_reject_collapsed_and_nonfinite_transforms() { let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { t.xf = Affine::new(matrix); assert!(t.effective_char_style(&t.first_style()).is_none()); } t.xf = Affine::rotate(0.83); assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); t.xf = Affine::scale(1e308); assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); } #[test] fn point_text_basics() { let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); assert_eq!(t.plain_text(), "Hello"); let b = t.bounds().unwrap(); assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); } fn justified(j: Justify) -> ParaStyle { ParaStyle { justify: j, ..ParaStyle::default() } } #[test] fn paragraph_styles_split_merge_and_normalize() { let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); assert_eq!(t.paragraphs_in(0, 0), 0..1); assert_eq!(t.paragraphs_in(4, 4), 1..2); assert_eq!(t.paragraphs_in(2, 9), 0..3); assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); // Return inside "two": the new paragraph continues its style. t.splice_paras(5, 5, "\n"); t.runs[0].text.insert(5, '\n'); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); // Deleting the break between "one" and "t": the first paragraph's style wins. t.splice_paras(3, 4, ""); t.runs[0].text.remove(3); assert_eq!(t.plain_text(), "onet\nwo\nthree"); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); // An edit that skips the splice is repaired by normalizing (the last style continues). t.runs[0].text.push_str("\nfour"); t.normalize_paras(); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); // `para` is paragraph 0's; all alike collapses to `para` alone. t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); assert_eq!(t.para.justify, Justify::Right); t.edit_paras(None, |p| p.justify = Justify::Right); assert!(t.paras.is_empty() && t.para.justify == Justify::Right); // A short list repeats its last entry, a long one is cut. t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); t.set_paragraph_styles(vec![justified(Justify::Center); 9]); assert!(t.paras.is_empty() && t.para.justify == Justify::Center); // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. t.edit_paras(Some(3..4), |p| p.space_before = 4.0); let json = serde_json::to_value(&t).unwrap(); assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); let mut old = json.clone(); old.as_object_mut().unwrap().remove("paras"); let back: TextObject = serde_json::from_value(old).unwrap(); assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); let back: TextObject = serde_json::from_value(json).unwrap(); assert_eq!(back.para_at(3).space_before, 4.0); } /// 120 × 40 area type at (40, 40), its text drawn at twice its size. fn area_type() -> TextObject { let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); t } #[test] fn area_resizes_its_frame_and_keeps_its_type() { let mut t = area_type(); assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); // Bottom-right handle to (200, 140), about the top-left corner. let o = Affine::translate((40.0, 40.0)); assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); let b = t.bounds().unwrap(); assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); // Point type has no area; a degenerate transform or a non-finite frame changes nothing. let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); let before = t.clone(); assert!(!t.transform_area(Affine::scale(f64::INFINITY))); t.xf = Affine::scale(0.0); assert!(!t.transform_area(Affine::scale(2.0))); t.xf = before.xf; assert_eq!(t, before); } #[test] fn area_anchors_move_in_document_space() { let mut t = area_type(); // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); let TextKind::Area { frame } = &t.kind else { panic!() }; assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); // A missing anchor changes nothing. let before = t.clone(); assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); assert_eq!(t, before); } /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore /// `justify_auto`); this build reads Auto back. #[test] fn auto_alignment_saves_readable_by_older_builds() { #[derive(Deserialize, Debug, PartialEq)] enum OldJustify { Left, Center, Right, JustifyLeft, JustifyCenter, JustifyRight, JustifyAll, } #[derive(Deserialize)] struct OldPara { justify: OldJustify, } #[derive(Deserialize)] struct OldText { para: OldPara, } for (direction, physical) in [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] { let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; let json = serde_json::to_string(&t).unwrap(); let old: OldText = serde_json::from_str(&json).unwrap(); assert_eq!(old.para.justify, physical, "{json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), t); } // Other alignments save as before, without the flag. let p = ParaStyle { justify: Justify::Right, ..Default::default() }; let json = serde_json::to_string(&p).unwrap(); assert!(!json.contains("justify_auto"), "{json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), p); // Files from before the flag keep their alignment. assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); }