refactor(doc): move text.rs tests to text/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:18 +00:00
parent 0f5658d270
commit d133961eb3
2 changed files with 336 additions and 337 deletions
+1 -337
View File
@@ -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::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(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::<TextObject>(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::<CharStyle>(old).unwrap().digits, Digits::Default);
assert_eq!(serde_json::from_value::<CharStyle>(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::<Vec<_>>();
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<Option<String>> = [(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::<ParaList>(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::<Vec<_>>();
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::<TextObject>(&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::<ParaStyle>(&json).unwrap(), p);
// Files from before the flag keep their alignment.
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
}
}
mod tests;
+335
View File
@@ -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::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(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::<TextObject>(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::<CharStyle>(old).unwrap().digits, Digits::Default);
assert_eq!(serde_json::from_value::<CharStyle>(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::<Vec<_>>();
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<Option<String>> = [(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::<ParaList>(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::<Vec<_>>();
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::<TextObject>(&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::<ParaStyle>(&json).unwrap(), p);
// Files from before the flag keep their alignment.
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
}