Files
vectorcraft/crates/doc/src/text/tests.rs
T
dotCooCoo 2606b70604 Type: Dynamic Text sizes type on a path to fill its brackets
Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar
for type on a path) scales the text by one factor so it fills the span
between its start and end brackets, and the layout fits it again after
each edit to the text, the path or a bracket. The first character's
size moves in 0.1 pt steps within 0.1 to 1,296 pt. Turning it off, or
threading the type, keeps the sizes it was drawn at.

- The model saves `dynamicText` only when on.
- SVG, the legacy .ai writer and the .ai editing data write the drawn
  sizes through `vectorcraft_text::drawn`; the editing data warns that
  the setting is not written.
- Shrink Text to Fit and Dynamic Text step the size the Character panel
  shows when a size measures the cap height, x-height or ICF box.

Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
Part of #4134.
2026-11-11 10:42:49 -08:00

518 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
/// Dynamic Text is off for new type and for type saved before it, and saved only when on.
#[test]
fn dynamic_text_is_saved_only_when_on() {
let t = TextObject::point(Point::ZERO, "Hi", CharStyle::default());
assert!(!t.dynamic_text);
let off = serde_json::to_value(&t).unwrap();
assert!(off.get("dynamicText").is_none());
assert!(!serde_json::from_value::<TextObject>(off).unwrap().dynamic_text);
let on = TextObject { dynamic_text: true, ..t };
let saved = serde_json::to_value(&on).unwrap();
assert_eq!(saved["dynamicText"], true);
assert!(serde_json::from_value::<TextObject>(saved).unwrap().dynamic_text);
}
/// 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);
}
/// The font height reference is the em box by default and is saved only when it is another.
#[test]
fn font_height_reference_is_saved_only_when_not_the_em_box() {
let st = CharStyle::default();
assert_eq!(st.font_height_reference, FontHeightReference::EmBox);
assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none());
for (r, key) in
[(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")]
{
let set = CharStyle { font_height_reference: r, ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
// Styles written before the attribute existed read as the em box.
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().font_height_reference, FontHeightReference::EmBox);
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
fn indic_digits_map_to_their_scripts_block() {
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
for (d, zero) in [
(Digits::Devanagari, 0x966),
(Digits::Bengali, 0x9E6),
(Digits::Gurmukhi, 0xA66),
(Digits::Gujarati, 0xAE6),
(Digits::Odia, 0xB66),
(Digits::Tamil, 0xBE6),
(Digits::Telugu, 0xC66),
(Digits::Kannada, 0xCE6),
(Digits::Malayalam, 0xD66),
] {
let block: String = (zero..zero + 10).map(|u| char::from_u32(u).unwrap()).collect();
assert!(block.chars().all(char::is_numeric), "{d:?}: {block}");
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
assert_eq!(map(d, s), block, "{d:?}");
}
assert_eq!(map(d, "a-Z ٪"), "a-Z ٪");
}
assert_eq!(map(Digits::Devanagari, "page 12"), "page १२");
for d in Digits::ALL {
assert_eq!(serde_json::to_value(d).unwrap(), d.id());
assert_eq!(serde_json::from_value::<Digits>(d.id().into()).unwrap(), d);
assert_eq!(Digits::from_id(d.id()), Some(d));
}
assert_eq!(Digits::from_id("roman"), None);
assert!(serde_json::from_value::<Digits>("roman".into()).is_err());
}
/// 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::<CharStyle>(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::<CharStyle>(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::<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 = ListCounts::default();
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.parts(), (1, vec![]));
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"})
);
}
/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`;
/// a list saved without it reads as Left.
#[test]
fn a_list_saves_its_marker_alignment_only_when_it_is_right() {
let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right);
assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left);
assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"}));
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right);
assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left);
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err());
}
/// 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, the hollow circle, the filled square and the dash. 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 (circle, square, dash) = (ParaList::bulleted(Bullet::Circle), ParaList::bulleted(Bullet::Square), ParaList::bulleted(Bullet::Dash));
let typed = ["1.", "1)", "A.", "A)", "*", "+", "#", ">"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, circle, square, dash].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, circle)));
// 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)));
}
/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1,
/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in
/// no list starts every level again. A level past the first sets the paragraph 18 pt further
/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the
/// nearest one. A type object's counts save level 1 and the deeper levels apart.
#[test]
fn list_levels_count_their_own_numbers_and_save_only_past_the_first() {
let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level));
let mut counts = ListCounts::default();
let markers: Vec<Option<String>> = [
(num(1), false),
(num(2), false),
(num(2), false),
(num(3), false),
(num(2), true),
(num(2), false),
(num(1), false),
(num(2), false),
(dot(1), false),
(num(2), false),
(num(1), false),
(None, false),
(num(2), false),
]
.iter()
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts))
.collect();
let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."];
assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::<Vec<_>>(), want);
assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0]));
assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1));
assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]);
assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]);
let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() };
assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0));
assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"}));
assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4}));
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap()));
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": "two"})).is_err());
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": 2.5})).is_err());
let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)];
assert_eq!(
levels.map(|l| read(serde_json::json!({ "level": l })).level),
[1, 1, 9, 9, 9],
"a saved level outside 1 to 9 reads as the nearest one"
);
let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]);
assert_eq!(deep.parts(), (5, vec![0, 2]));
assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![])));
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
t.set_list_counts(deep);
assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep));
let saved = serde_json::to_value(&t).unwrap();
assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2])));
t.set_list_counts(ListCounts::default());
assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them");
let mut saved = serde_json::to_value(&t).unwrap();
saved["listLevelCounts"] = serde_json::json!((1..=20).collect::<Vec<u32>>());
let opened = serde_json::from_value::<TextObject>(saved).unwrap();
assert_eq!(opened.list_level_counts, (1..=8).collect::<Vec<u32>>(), "a file keeps only the counts of levels 2 to 9");
}
/// In a threaded frame whose first paragraph continues from the frame before, the text at the
/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing.
/// The paragraphs after it still start lists.
#[test]
fn typed_list_starts_skip_a_continued_paragraph() {
let dot = ParaList::bulleted(Bullet::Dot);
let mut t = TextObject::point(Point::ZERO, "* Pears\n* ", CharStyle::default());
assert_eq!(t.typed_list_at(2), Some((0, dot)));
t.first_para_continues = true;
assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list");
assert_eq!(t.typed_list_at(10), Some((8, dot)));
}
#[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);
}
/// Text Flow is saved only when by rows: area type by columns (the default) saves as it did before
/// the option, and files from before it read by columns; other values don't read.
#[test]
fn text_flow_is_saved_only_when_by_rows() {
let by_columns = serde_json::to_value(AreaOptions::default()).unwrap();
assert!(by_columns.get("flow").is_none());
assert_eq!(serde_json::from_value::<AreaOptions>(by_columns).unwrap().flow, TextFlow::Columns);
let by_rows = serde_json::to_value(AreaOptions { flow: TextFlow::Rows, ..AreaOptions::default() }).unwrap();
assert_eq!(by_rows["flow"], "rows");
assert_eq!(serde_json::from_value::<AreaOptions>(by_rows).unwrap().flow, TextFlow::Rows);
assert!(serde_json::from_value::<AreaOptions>(serde_json::json!({"flow": "diagonal"})).is_err());
}
/// 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);
}