From d133961eb3c87857a1cffbab364da45a535294e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:21 +0000 Subject: [PATCH] refactor(doc): move text.rs tests to text/tests.rs --- crates/doc/src/text.rs | 338 +---------------------------------- crates/doc/src/text/tests.rs | 335 ++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 337 deletions(-) create mode 100644 crates/doc/src/text/tests.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 067eae7b4..f95c9ee28 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -2201,340 +2201,4 @@ impl crate::Document { } #[cfg(test)] -mod tests { - 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); - } - - /// 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); - } -} +mod tests; diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs new file mode 100644 index 000000000..54142efd7 --- /dev/null +++ b/crates/doc/src/text/tests.rs @@ -0,0 +1,335 @@ +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); +} + +/// 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); +}