mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 01:15:25 +00:00
Character panel > Tsume, shown with Show East Asian Options, takes away a percentage of the blank space on each side of a character's ink, inside its own width, in horizontal type. The value is from 0 to 100, 0 by default. 50 halves that space and 100 sets the ink against the characters before and after it; the glyph keeps its shape. A character without ink, such as a space, keeps its width, and a ligature or a letter with its marks takes the value of its first character. The font's pair kerning, tracking, manual kerning and Insert Aki are added after it, and justification keeps it. Vertical type keeps its spacing. - text.setFormat and text.setRangeStyle take `tsume`; a value that is not a number or is outside 0 to 100 is refused, naming the key. Character styles carry it, and a file holding a value outside the range opens with the nearer end. - The Character panel's Tsume field has 0, 25, 50, 75 and 100 in its menu, and Reset Panel sets it to 0. Character Style Options shows it in Advanced Character Formats. - Live SVG text places each character Tsume moves, and type on a path with Tsume is written as outlines. Docs: docs/mcp.md (Tsume, Character Style Options fields), docs/target-app-parity.md.
606 lines
32 KiB
Rust
606 lines
32 KiB
Rust
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);
|
||
}
|
||
|
||
/// Insert Aki Left and Right are 0 by default and saved only when not 0, each side on its own;
|
||
/// styles written before them read as 0, and a value out of range from a file is kept from 0 to one
|
||
/// em.
|
||
#[test]
|
||
fn insert_aki_is_saved_only_when_set() {
|
||
let st = CharStyle::default();
|
||
assert_eq!((st.aki_left, st.aki_right), (0.0, 0.0));
|
||
let saved = serde_json::to_value(&st).unwrap();
|
||
assert!(saved.get("akiLeft").is_none() && saved.get("akiRight").is_none());
|
||
for set in [
|
||
CharStyle { aki_left: 250.0, ..CharStyle::default() },
|
||
CharStyle { aki_right: 500.0, ..CharStyle::default() },
|
||
CharStyle { aki_left: 125.5, aki_right: 1000.0, ..CharStyle::default() },
|
||
] {
|
||
let saved = serde_json::to_value(&set).unwrap();
|
||
assert_eq!(saved.get("akiLeft").and_then(serde_json::Value::as_f64).unwrap_or(0.0), set.aki_left);
|
||
assert_eq!(saved.get("akiRight").and_then(serde_json::Value::as_f64).unwrap_or(0.0), set.aki_right);
|
||
assert_eq!(saved.get("akiLeft").is_some(), set.aki_left != 0.0);
|
||
assert_eq!(serde_json::from_value::<CharStyle>(saved).unwrap(), set);
|
||
}
|
||
let old = serde_json::to_value(&st).unwrap();
|
||
let read = serde_json::from_value::<CharStyle>(old).unwrap();
|
||
assert_eq!((read.aki_left, read.aki_right), (0.0, 0.0));
|
||
let mut damaged = CharStyle { aki_left: -20.0, aki_right: 4000.0, ..CharStyle::default() };
|
||
damaged.keep_dimensions_in_range();
|
||
assert_eq!((damaged.aki_left, damaged.aki_right), (0.0, MAX_AKI));
|
||
let mut damaged = CharStyle { aki_left: f64::NAN, aki_right: f64::INFINITY, ..CharStyle::default() };
|
||
damaged.keep_dimensions_in_range();
|
||
assert_eq!((damaged.aki_left, damaged.aki_right), (0.0, MAX_AKI));
|
||
// A character style from a file is kept in range the same way.
|
||
let mut d = crate::Document::new(100.0, 100.0);
|
||
let attrs = serde_json::json!({"akiLeft": 5000, "akiRight": -1});
|
||
d.char_styles.push(TextStyleDef { name: "Spaced".into(), attrs: attrs.as_object().cloned().unwrap_or_default() });
|
||
d.keep_type_values_in_range();
|
||
assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0)));
|
||
}
|
||
|
||
/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a
|
||
/// value out of range from a file, in a run or a character style, is kept from 0 to 100.
|
||
#[test]
|
||
fn tsume_is_saved_only_when_set_and_kept_in_range() {
|
||
let st = CharStyle::default();
|
||
assert_eq!(st.tsume, 0.0);
|
||
let saved = serde_json::to_value(&st).unwrap();
|
||
assert!(saved.get("tsume").is_none());
|
||
let read = serde_json::from_value::<CharStyle>(saved).unwrap();
|
||
assert_eq!(read.tsume, 0.0);
|
||
let set = CharStyle { tsume: 37.5, ..CharStyle::default() };
|
||
let saved = serde_json::to_value(&set).unwrap();
|
||
assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5));
|
||
assert_eq!(serde_json::from_value::<CharStyle>(saved).unwrap(), set);
|
||
for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] {
|
||
let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() };
|
||
damaged.keep_dimensions_in_range();
|
||
assert_eq!(damaged.tsume, kept, "{stored}");
|
||
}
|
||
let mut d = crate::Document::new(100.0, 100.0);
|
||
for tsume in [500, -1] {
|
||
let attrs = serde_json::json!({ "tsume": tsume });
|
||
d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() });
|
||
}
|
||
d.keep_type_values_in_range();
|
||
assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0)));
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph
|
||
/// saved before it existed, or a text object saved without paragraph attributes, reads with it on,
|
||
/// and both values read back as saved.
|
||
#[test]
|
||
fn korean_line_break_anywhere_is_saved_only_when_off() {
|
||
let off = ParaStyle::default();
|
||
assert!(!off.korean_line_break_anywhere);
|
||
let saved = serde_json::to_value(&off).unwrap();
|
||
assert_eq!(saved["korean_line_break_anywhere"], false);
|
||
assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), off);
|
||
let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() };
|
||
let saved = serde_json::to_value(&on).unwrap();
|
||
assert!(saved.get("korean_line_break_anywhere").is_none());
|
||
assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), on);
|
||
let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap();
|
||
assert!(old.korean_line_break_anywhere);
|
||
let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap();
|
||
assert_eq!(saved["para"]["korean_line_break_anywhere"], false);
|
||
saved.as_object_mut().unwrap().remove("para");
|
||
let old: TextObject = serde_json::from_value(saved).unwrap();
|
||
assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() });
|
||
}
|
||
|
||
/// 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);
|
||
}
|