mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 01:50:22 -06:00
412 lines
21 KiB
Rust
412 lines
21 KiB
Rust
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);
|
||
}
|
||
|
||
/// 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 = 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);
|
||
}
|
||
|
||
/// 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);
|
||
}
|