diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 179c0de516..a62dce9ec5 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -132,9 +132,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { } /// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's -/// opacity (how far the art mixes towards it). `None` for an unstained instance. +/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one +/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. pub fn stain(inst: &Node) -> Option<(Color, f32)> { - let f = inst.appearance.fill().filter(|f| f.visible)?; + let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; Some((f.paint.color()?, f.opacity)) } diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 7871b61af6..22aad6603c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -764,6 +764,26 @@ fn stain_mixes_type_strokes_too() { assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]); } +/// An instance whose fill has an opacity of 0 does not stain its symbol's art, so the art keeps its +/// swatch links. +#[test] +fn a_fill_with_no_opacity_leaves_the_symbol_art_linked() { + let spot = Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4), swatch: Some("Spot".into()), tint: 1.0 }; + let art = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(spot.clone(), Paint::None, 0.0)); + let mut inst = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::IDENTITY }); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + if let Some(f) = inst.appearance.fill_mut() { + f.opacity = 0.0; + } + assert_eq!(stain(&inst), None); + assert_eq!(instance_art(&art, &inst).appearance.fill_paint(), spot); + // Any opacity above 0 still stains. + if let Some(f) = inst.appearance.fill_mut() { + f.opacity = 0.01; + } + assert_ne!(instance_art(&art, &inst).appearance.fill_paint(), spot); +} + #[test] fn library_defaults_store_and_find() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 4d9ffaef9d..33fa1a5142 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1203,8 +1203,9 @@ fn own_glyph( /// The glyph of `face` drawn for the character `ch` at `bytes` of `t` when the character is shaped /// alone in the writing direction of `t` and the direction its paragraph sets it in, with the rest /// of its grapheme in the grapheme's own styles: the glyph of a character that is not laid out -/// (overflowing text). -fn shaped_alone(t: &TextObject, ch: char, bytes: &std::ops::Range, face: &vectorcraft_text::FontFace) -> Option { +/// (overflowing text). Also the glyph drawn for the character in whatever face draws it +/// ([`drawn_glyph`]). +fn shaped_alone(t: &TextObject, ch: char, bytes: &std::ops::Range, face: &vectorcraft_text::FontFace) -> (Option, Option) { use vectorcraft_doc::ParaDirection; let plain = t.plain_text(); let end = edit::next_cluster(&plain, bytes.start).max(bytes.end); @@ -1218,7 +1219,27 @@ fn shaped_alone(t: &TextObject, ch: char, bytes: &std::ops::Range, face: let rtl = vectorcraft_text::char_is_rtl(plain.get(start..stop).unwrap_or_default(), t.para_at(paragraph).direction, bytes.start - start); single.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &single); - own_glyph(&single.plain_text(), &lay, ch, &(0..bytes.len()), face).flatten() + let (alone, at) = (single.plain_text(), 0..bytes.len()); + (own_glyph(&alone, &lay, ch, &at, face).flatten(), drawn_glyph(&alone, &lay, ch, &at, face)) +} + +/// The glyph `lay` draws for the character `ch` at `bytes` of `plain` in whatever face draws it: +/// the glyph of `face` ([`own_glyph`]); else, when a fallback face draws the character, that face's +/// glyph for it by the same rule (its vowel sign drawn first is not reported); else the first glyph +/// drawn for the character. `None` when the character is not laid out. +fn drawn_glyph( + plain: &str, + lay: &vectorcraft_text::TextLayout, + ch: char, + bytes: &std::ops::Range, + face: &vectorcraft_text::FontFace, +) -> Option { + if let Some(own) = own_glyph(plain, lay, ch, bytes, face).flatten() { + return Some(own); + } + let first = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len))?; + let fallback = (first.font_id != face.id()).then(|| vectorcraft_text::FontDb::global().face_by_id(first.font_id)).flatten(); + fallback.and_then(|f| own_glyph(plain, lay, ch, bytes, &f).flatten()).or(Some(first.gid)) } fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { @@ -1253,7 +1274,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let mut probe = t.clone(); edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); - own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face)) + own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0) }); let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { @@ -1302,14 +1323,10 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result { let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - let first = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)).map(|g| g.gid); // A character that is not laid out (overflowing text) reports the glyph it is drawn with when - // it is shaped alone, as `text.setGlyphAlternate` finds it. - let chosen = match own_glyph(&t.plain_text(), &lay, ch, &bytes, &face) { - Some(own) => own.or(first), - None => shaped_alone(&t, ch, &bytes, &face), - } - .unwrap_or(default); + // it is shaped alone, as `text.setGlyphAlternate` finds it; a fallback face's glyph when it is + // drawn in one. + let chosen = drawn_glyph(&t.plain_text(), &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default); let alternates: Vec = face.alternates(ch).iter().map(|a| json!({"gid": a.gid, "feature": String::from_utf8_lossy(&a.feature)})).collect(); Ok(json!({"char": ch.to_string(), "font": face.family, "style": face.style, "default": default, "chosen": chosen, "alternates": alternates})) } diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index c84fb9588c..090cb98285 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -75,6 +75,34 @@ fn symbol_instance_tint_survives_expanding_an_object_effect() { assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); } +/// An instance fill with an opacity of 0 does not tint the symbol's art, so expanding keeps the +/// art's global swatch link. +#[test] +fn a_symbol_instance_fill_with_no_opacity_keeps_swatch_links_when_expanded() { + let mut s = session(); + let sq = square(&mut s); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true})); + run(&mut s, "paint.setFill", json!({"ids": [sq.0], "swatch": "Brand"})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Linked Square"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let fill = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": fill, "color": "#00ff00", "opacity": 0})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "item": null, "effect": "path.offsetPath", "params": {"offset": 6}})); + assert!(expandable(&s)); + + run(&mut s, "effect.expandAppearance", json!({})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + let mut linked = 0; + n.walk(&mut |part| { + if let vectorcraft_color::Paint::Solid { swatch: Some(name), .. } = part.appearance.fill_paint() { + assert_eq!(name, "Brand"); + linked += 1; + } + }); + assert!(linked > 0, "the expanded art keeps the Brand swatch link"); +} + #[test] fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { let mut s = session(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index a35ca13a8d..9f607d78a3 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -2793,4 +2793,44 @@ fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { let query = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 0})).unwrap(); assert_eq!(query["chosen"], json!(glyph.gid), "the query reports the visible fallback glyph"); assert!(query["alternates"].as_array().unwrap().is_empty()); + + let area = json!({"x": 0, "y": 300, "text": ch.to_string(), "font": alternates_family(true), "area": {"width": 200, "height": 2}}); + let boxed = NodeId(s.execute("text.create", &area).unwrap()["id"].as_u64().unwrap()); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + let query = s.execute("text.glyphAlternates", &json!({"id": boxed.0, "start": 0})).unwrap(); + assert_eq!(query["chosen"], json!(glyph.gid), "an overflowing character reports the fallback glyph it is drawn with"); +} + +/// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph +/// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel +/// sign's. +#[test] +fn the_fallback_glyph_of_a_consonant_after_its_vowel_sign_is_reported() { + use vectorcraft_text::test_fonts::{MATRA_FAMILY, alternates_family, alternates_font, matra_font}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + db.add_font(matra_font().expect("the test font builds")); + let requested = db.face(alternates_family(true), "Regular").expect("the test face"); + let text = "क\u{93F}"; + assert_eq!(requested.glyph_for('क'), 0); + let mut s = session(); + let create = |s: &mut Session, height: f64| { + let spec = json!({"x": 0, "y": 0, "text": text, "font": alternates_family(true), "area": {"width": 200, "height": height}}); + NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap()) + }; + let open = create(&mut s, 200.0); + let glyphs = vectorcraft_text::layout(db, &obj(&s, open)).glyphs; + let fallback = db.face_by_id(glyphs[0].font_id).expect("the fallback face"); + assert_ne!(fallback.id(), requested.id()); + let (ka, sign) = (fallback.glyph_for('क'), fallback.glyph_for('\u{93F}')); + let drawn: Vec<_> = glyphs.iter().map(|g| (g.gid, g.byte)).collect(); + assert_eq!(drawn, [(sign, 0), (ka, 0)], "the vowel sign is drawn first, in Ka's cluster ({} or {MATRA_FAMILY})", fallback.family); + let query = s.execute("text.glyphAlternates", &json!({"id": open.0, "start": 0})).unwrap(); + assert_eq!(query["chosen"], json!(ka), "the fallback face's glyph for Ka"); + + let boxed = create(&mut s, 2.0); + assert!(vectorcraft_text::layout(db, &obj(&s, boxed)).glyphs.is_empty(), "the text overflows"); + let query = s.execute("text.glyphAlternates", &json!({"id": boxed.0, "start": 0})).unwrap(); + assert_eq!(query["chosen"], json!(ka), "an overflowing Ka reports the fallback face's glyph for Ka"); } diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 387a1a3420..01510cd0d7 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -784,15 +784,26 @@ fn end_segment( } } +#[cfg(test)] +thread_local! { + /// Tests: how many times [`reader_advances`] laid out type again on this thread. + static READER_LAYOUTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The advance a reader of live SVG text gives each character cluster of `t` and how far from /// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out /// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by /// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual -/// kerning leaves it out). +/// kerning leaves it out). When that leaves `t` as it is, its glyphs are read from `lay`, which +/// is `t`'s layout, instead of laying it out again. fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + // Mojikumi that spaces none of the characters sets them as Mojikumi None does. + let mojikumi = lay.mojikumi_applies(&t.plain_text()); let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { - p.mojikumi = vectorcraft_doc::Mojikumi::None; + if mojikumi { + p.mojikumi = vectorcraft_doc::Mojikumi::None; + } // A reader spaces words and letters as the font does. p.justification = vectorcraft_doc::JustificationOptions::default(); } @@ -801,8 +812,17 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: r.style.kerning.get_or_insert(0.0); } } + let relaid; + let glyphs = if set == *t { + &lay.glyphs + } else { + #[cfg(test)] + READER_LAYOUTS.with(|n| n.set(n.get() + 1)); + relaid = vectorcraft_text::layout(FontDb::global(), &set); + &relaid.glyphs + }; let mut out: HashMap = HashMap::new(); - for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + for g in glyphs.iter().filter(|g| g.len > 0) { let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); *advance += g.advance * lay.fit_scale; } @@ -819,3 +839,6 @@ fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { }; Some((x, first.y)) } + +#[cfg(test)] +mod tests; diff --git a/crates/svg/src/export/text/tests.rs b/crates/svg/src/export/text/tests.rs new file mode 100644 index 0000000000..fa8ca0455c --- /dev/null +++ b/crates/svg/src/export/text/tests.rs @@ -0,0 +1,136 @@ +//! The advances a reader gives live type ([`reader_advances`]): the same as when the type is laid +//! out again each time, which happens only when a reader sets the type differently from its layout. + +use super::*; +use vectorcraft_doc::text::{Bullet, ParaList}; +use vectorcraft_doc::{Document, JustificationOptions, Mojikumi, TextRun}; +use vectorcraft_geom::{Point, shapes}; + +/// The advances with `t` laid out again as a reader sets it, whatever it holds. +fn laid_out_again(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; + for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { + p.mojikumi = Mojikumi::None; + p.justification = JustificationOptions::default(); + } + if !kerning { + for r in &mut set.runs { + r.style.kerning.get_or_insert(0.0); + } + } + let mut out: HashMap = HashMap::new(); + for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); + *advance += g.advance * lay.fit_scale; + } + out +} + +fn style() -> CharStyle { + CharStyle { size: 12.0, fill: Paint::solid(vectorcraft_color::Color::BLACK), ..CharStyle::default() } +} + +/// Point type with Line-end Punctuation Half Width, the setting new type gets. +fn point(text: &str) -> TextObject { + let mut t = TextObject::point(Point::new(20.0, 20.0), text, style()); + t.para.mojikumi = Mojikumi::LineEndHalf; + t +} + +/// How many times [`reader_advances`] laid out `t` again (`kerning` as it is given), after checking +/// that it gives what laying out again gives. +fn layouts_again(t: &TextObject, kerning: bool) -> usize { + let lay = vectorcraft_text::layout(FontDb::global(), t); + READER_LAYOUTS.with(|n| n.set(0)); + let got = reader_advances(t, &lay, kerning); + let again = READER_LAYOUTS.with(std::cell::Cell::get); + assert_eq!(got, laid_out_again(t, &lay, kerning), "{:?}", t.plain_text()); + again +} + +/// SVG export of `texts`, each a type object, and how many times it laid type out again for its +/// reader advances. +fn export_layouts_again(texts: Vec) -> (String, usize) { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + for t in texts { + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + d.insert(Some(l), usize::MAX, n).unwrap(); + } + READER_LAYOUTS.with(|n| n.set(0)); + let svg = crate::export(&d, &crate::ExportOptions::default()); + (svg, READER_LAYOUTS.with(std::cell::Cell::get)) +} + +#[test] +fn latin_type_with_mojikumi_is_not_laid_out_again() { + assert_eq!(layouts_again(&point("Latin words, digits 2026 and (brackets)."), true), 0); + let (svg, again) = export_layouts_again(vec![point("Ab0"), point("Ab1"), point("Ab2")]); + assert_eq!(again, 0, "{svg}"); + assert!(svg.contains(">Ab1"), "{svg}"); +} + +#[test] +fn empty_and_one_character_type() { + assert_eq!(layouts_again(&point(""), true), 0); + assert_eq!(layouts_again(&point("A"), true), 0); + assert_eq!(layouts_again(&point("A"), false), 1); + let (svg, again) = export_layouts_again(vec![point(""), point("A")]); + assert_eq!(again, 0, "{svg}"); +} + +#[test] +fn japanese_type_with_mojikumi_is_laid_out_again() { + for text in ["「かな」、漢字とLatin。", "雅", "、"] { + assert_eq!(layouts_again(&point(text), true), 1, "{text}"); + } + // Without Mojikumi, a reader sets it as the layout does. + let mut none = point("「かな」、漢字とLatin。"); + none.para.mojikumi = Mojikumi::None; + assert_eq!(layouts_again(&none, true), 0); +} + +#[test] +fn a_japanese_run_or_paragraph_lays_the_whole_object_out_again() { + let mut runs = point("Latin "); + runs.runs.push(TextRun::new("漢字", style())); + assert_eq!(layouts_again(&runs, true), 1); + let paras = point("Latin\n漢字"); + assert_eq!(layouts_again(¶s, true), 1); +} + +#[test] +fn area_and_justified_type_is_laid_out_again() { + let mut area = point("aa bb cc dd ee ff"); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 160.0)) }; + area.para.justify = Justify::JustifyLeft; + assert_eq!(layouts_again(&area, true), 1); + let mut spaced = point("Latin words"); + spaced.para.justification = JustificationOptions { letter_spacing: [0.0, 5.0, 10.0], ..JustificationOptions::default() }; + assert_eq!(layouts_again(&spaced, true), 1); +} + +#[test] +fn unkerned_advances_lay_out_again_only_for_runs_without_manual_kerning() { + let mut wide = point("AVAV"); + wide.runs[0].style.h_scale = 150.0; + assert_eq!(layouts_again(&wide, false), 1); + wide.runs[0].style.kerning = Some(0.0); + assert_eq!(layouts_again(&wide, false), 0); +} + +#[test] +fn list_items_with_latin_markers_are_not_laid_out_again() { + let mut bullets = point("first\nsecond"); + bullets.para.list = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!(layouts_again(&bullets, true), 0); + let mut numbers = point("一つ\n二つ"); + numbers.para.list = Some(ParaList::numbered(vectorcraft_doc::text::NumberStyle::Arabic)); + assert_eq!(layouts_again(&numbers, true), 1); +} + +#[test] +fn a_mixed_document_lays_out_again_only_the_japanese_type() { + let (svg, again) = export_layouts_again(vec![point("Latin"), point("「かな」。"), point(""), point("Ab2")]); + assert_eq!(again, 1, "{svg}"); +} diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index 372c70b2fd..15a4338c15 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -145,6 +145,17 @@ fn is_japanese_letter(c: char) -> bool { && punct(c).is_none() } +impl TextLayout { + /// Whether Line-end Punctuation Half Width (Mojikumi) can set type laid out as this layout + /// differently from Mojikumi None: `text`, the type's plain text, or one of the layout's list + /// markers holds Japanese punctuation or a Japanese letter, the only characters it spaces. + /// When it holds neither, the type lays out the same with either setting. + pub fn mojikumi_applies(&self, text: &str) -> bool { + let spaced = |c: char| punct(c).is_some() || is_japanese_letter(c); + text.chars().any(spaced) || self.markers.iter().any(|m| m.text.chars().any(spaced)) + } +} + /// A Latin letter or digit (or another script's letter), set proportionally. fn is_latin_letter(c: char) -> bool { c.is_alphanumeric() && !is_cjk(c) diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index c0a2b5e13f..35e8260da3 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -688,6 +688,37 @@ fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() { assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01)); } +/// Mojikumi applies to type holding Japanese punctuation or a Japanese letter; type without either +/// lays out the same with Line-end Punctuation Half Width as with Mojikumi None, across lines and +/// list markers too. +#[test] +fn mojikumi_applies_only_to_japanese_punctuation_and_letters() { + use vectorcraft_doc::Mojikumi; + use vectorcraft_doc::text::{Bullet, ParaList}; + let styled = |text: &str, m: Mojikumi| { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.para.mojikumi = m; + t + }; + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.advance, g.origin, g.xf)).collect::>(); + for text in ["", "A", "Latin words, digits 2026 and (brackets).", "first\nsecond"] { + let on = styled(text, Mojikumi::LineEndHalf); + let lay = layout(FontDb::global(), &on); + assert!(!lay.mojikumi_applies(&on.plain_text()), "{text:?}"); + let off = layout(FontDb::global(), &styled(text, Mojikumi::None)); + assert_eq!(placed(&lay), placed(&off), "{text:?}"); + assert_eq!(lay.bounds, off.bounds, "{text:?}"); + } + let mut listed = styled("first\nsecond", Mojikumi::LineEndHalf); + listed.para.list = Some(ParaList::bulleted(Bullet::Dot)); + assert!(!layout(FontDb::global(), &listed).mojikumi_applies(&listed.plain_text())); + for text in ["、", "「A」", "雅", "々", "Latin 漢字"] { + let t = styled(text, Mojikumi::LineEndHalf); + assert!(layout(FontDb::global(), &t).mojikumi_applies(&t.plain_text()), "{text:?}"); + } +} + /// Mojikumi (JLREQ 3.1.5): with Line-end Punctuation Half Width, an opening bracket starting a /// wrapped line is set flush with the line's start (the space before it goes), giving the line half /// an em more room; at the start of a paragraph too, so its face starts at the first-line indent diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index 3cbdf2c9ea..ac382547de 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -149,9 +149,9 @@ impl VectorcraftApp { /// The text of the latest Copy or Cut whose formats could not be written to the system /// clipboard, while nothing has been copied since that Copy or Cut. A paste into type or a text - /// field takes this text until another app copies something. Where the platform has no change count, a - /// copy is noticed only when it changes the clipboard's text, and once a clipboard whose text - /// could not be read when the write failed has text, Paste takes that text. + /// field takes this text until another app copies something. Where the platform has no change + /// count, a copy is noticed only when it changes the clipboard's text, so the copy is kept only + /// when the clipboard held text when the write failed. pub(crate) fn unwritten_clipboard_text(&mut self) -> Option { let cb = self.services.system_clipboard.as_deref_mut()?; let (held, text) = self.clipboard_publishing.unwritten.as_ref()?; @@ -208,15 +208,28 @@ impl VectorcraftApp { let formats = r.and_then(|_| made.try_recv().map_err(|e| e.to_string())); let written = formats.and_then(|(f, warnings)| { let Some(cb) = app.services.system_clipboard.as_mut() else { return Ok(warnings) }; + let before_write = Held::now(cb.as_mut()); // Nothing copied elsewhere since the Copy or Cut. - let current = before.as_ref().is_some_and(|b| *b == Held::now(cb.as_mut())); + let current = before.as_ref() == Some(&before_write); if let Err(e) = cb.write(&f) { // Another app may hold the clipboard open. It keeps what it held (or what the // failed write left), and a paste into text takes this copy's text until // something else is copied. let held = Held::now(cb.as_mut()); let text = f.iter().find(|t| t.mime == TEXT).map(|t| String::from_utf8_lossy(&t.data).into_owned()); - app.clipboard_publishing.unwritten = text.filter(|_| current).map(|text| (held, text)); + // A change during the write is the failed write emptying the clipboard and + // writing some of the formats, or another app's copy: the clipboard then holds + // nothing to paste, this copy's text, or no text and one of this copy's + // formats only after the former. + let left_by_write = held == before_write + || match cb.read(&[TEXT]) { + Some(now) => text.as_deref().is_some_and(|ours| now.data == ours.as_bytes()), + None => !cb.has(&PASTE_ORDER) || f.iter().any(|ours| cb.read(&[ours.mime]).is_some_and(|now| now.data == ours.data)), + }; + // Without a change count, a clipboard with no text can't show a later copy of + // a picture, or tell an empty clipboard from one that couldn't be read. + let trackable = held != Held::Text(None); + app.clipboard_publishing.unwritten = text.filter(|_| current && left_by_write && trackable).map(|text| (held, text)); return Err(e); } Ok(warnings) diff --git a/crates/ui-egui/src/tests/clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs index c4f0e3a745..77ff0149b4 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -320,13 +320,16 @@ fn type_paste_keeps_a_copy_the_system_clipboard_refused_until_another_app_copies assert_eq!(old.style.size, 12.0, "another app's text takes the destination's style"); } -#[test] -fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unchanged() { - let _guard = TEST_CLIP_LOCK.lock().unwrap(); +/// An app whose fake clipboard has no change count and holds `held`; "Hello" (36 pt) copied in the +/// Type tool with `refuse` set up by `before_copy`; and a paste at `at` into "Own: " (12 pt). +fn uncounted_refused_copy( + held: (&'static str, Vec), + before_copy: impl FnOnce(&std::rc::Rc>), +) -> (VectorcraftApp, std::rc::Rc>, impl Fn(&mut VectorcraftApp, usize) -> String) { let (mut app, board) = super::sysclip::app(); super::sysclip::without_change_count(&board); - super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); - super::sysclip::lock(&board, true); + super::sysclip::copy_elsewhere(&board, vec![held]); + before_copy(&board); let source = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); let v = app.view_info(); app.session.select_tool("type", v).unwrap(); @@ -336,7 +339,7 @@ fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unch let destination = run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); - let paste_at = |app: &mut VectorcraftApp, at: usize| { + let paste_at = move |app: &mut VectorcraftApp, at: usize| { app.session.select_tool("selection", v).unwrap(); app.session.select_tool("type", v).unwrap(); app.session.set_tool_option("textSelection", &json!({"id": destination, "start": at, "end": at})); @@ -345,11 +348,36 @@ fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unch let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!() }; t.plain_text() }; - // The clipboard still can't be read, as when the write failed. + (app, board, paste_at) +} + +#[test] +fn type_paste_without_a_change_count_keeps_a_refused_copy_while_the_text_is_unchanged() { + use vectorcraft_engine::cmd::clipboard::TEXT; + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let (mut app, board, paste_at) = uncounted_refused_copy((TEXT, b"Old".to_vec()), |b| super::sysclip::refuse_writes(b, true)); assert_eq!(paste_at(&mut app, 5), "Own: Hello"); - // Its text read later may be another app's copy: Paste takes it. + super::sysclip::copy_elsewhere(&board, vec![(TEXT, b"New".to_vec())]); + assert_eq!(paste_at(&mut app, 5), "Own: NewHello"); +} + +#[test] +fn type_paste_without_a_change_count_drops_a_refused_copy_it_cannot_track() { + use vectorcraft_engine::cmd::clipboard::{PNG, TEXT}; + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + // The clipboard can't be read when the write fails: its text read later may be another app's. + let (mut app, board, paste_at) = uncounted_refused_copy((TEXT, b"Old".to_vec()), |b| super::sysclip::lock(b, true)); + assert_eq!(paste_at(&mut app, 5), "Own: "); super::sysclip::lock(&board, false); - assert_eq!(paste_at(&mut app, 10), "Own: HelloOld"); + assert_eq!(paste_at(&mut app, 5), "Own: Old"); + + // The clipboard holds a picture, and another app copies a different picture: no text pastes. + let (mut app, board, paste_at) = uncounted_refused_copy((PNG, super::sysclip::blue_png()), |b| { + super::sysclip::has_text_only(b); + super::sysclip::refuse_writes(b, true); + }); + super::sysclip::copy_elsewhere(&board, vec![(PNG, super::sysclip::blue_png().into_iter().rev().collect())]); + assert_eq!(paste_at(&mut app, 5), "Own: "); } #[test] @@ -411,9 +439,17 @@ fn a_copy_elsewhere_before_a_refused_write_wins_the_type_paste() { #[test] fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() { let _guard = TEST_CLIP_LOCK.lock().unwrap(); + assert_eq!(paste_after_a_write_that_failed_after_emptying(false), "Own: Hello"); + // The formats other than text were written before the failure. + assert_eq!(paste_after_a_write_that_failed_after_emptying(true), "Own: Hello"); +} + +/// Copy "Hello" in the Type tool with a write that empties the clipboard and fails (`keep_others`: +/// after writing the formats other than text), then paste into "Own: ". +fn paste_after_a_write_that_failed_after_emptying(keep_others: bool) -> String { let (mut app, board) = super::sysclip::app(); super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); - super::sysclip::fail_writes_after_emptying(&board); + super::sysclip::fail_writes_after_emptying(&board, keep_others); let source = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); let destination = run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); @@ -429,7 +465,42 @@ fn type_paste_keeps_a_copy_whose_write_failed_after_emptying_the_clipboard() { crate::panels::character::intercept_text_command(&mut app, "edit.paste"); let node = app.session.doc().unwrap().doc.node(NodeId(destination)).unwrap(); let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!() }; - assert_eq!(t.plain_text(), "Own: Hello"); + t.plain_text() +} + +#[test] +fn a_copy_elsewhere_during_a_refused_write_wins_the_type_paste() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + use vectorcraft_engine::cmd::clipboard::{PNG, TEXT}; + assert_eq!(paste_after_a_copy_during_a_refused_write(TEXT, b"Race".to_vec(), false), "Own: Race"); + // Its other formats may match this copy's; its text still differs. + assert_eq!(paste_after_a_copy_during_a_refused_write(TEXT, b"Race".to_vec(), true), "Own: Race"); + // A picture has no text to paste into type. + assert_eq!(paste_after_a_copy_during_a_refused_write(PNG, super::sysclip::blue_png(), false), "Own: "); +} + +/// Copy in the Type tool while another app copies `data` of type `mime` (with `our_formats`, also +/// the copy's formats other than text) and the write fails, then paste into "Own: ". +fn paste_after_a_copy_during_a_refused_write(mime: &'static str, data: Vec, our_formats: bool) -> String { + let (mut app, board) = super::sysclip::app(); + super::sysclip::copy_elsewhere(&board, vec![(vectorcraft_engine::cmd::clipboard::TEXT, b"Old".to_vec())]); + super::sysclip::copy_during_next_write(&board, mime, data, our_formats); + let source = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); + let destination = + run(&mut app, "text.create", json!({"x": 20, "y": 60, "text": "Own: ", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); + let v = app.view_info(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": source, "start": 0, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.copy"); + crate::background::wait_all(&mut app); + + app.session.select_tool("selection", v).unwrap(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": destination, "start": 5, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.paste"); + let node = app.session.doc().unwrap().doc.node(NodeId(destination)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!() }; + t.plain_text() } /// https://helpx.adobe.com/illustrator/using/format-text.html diff --git a/crates/ui-egui/src/tests/fonts.rs b/crates/ui-egui/src/tests/fonts.rs index f8f019171e..073be1e083 100644 --- a/crates/ui-egui/src/tests/fonts.rs +++ b/crates/ui-egui/src/tests/fonts.rs @@ -9,6 +9,10 @@ use crate::{VectorcraftApp, menus}; #[test] fn type_font_menu_lists_every_available_family() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); + // The app starts the installed fonts' scan, and until it ends Type › Font lists only the + // loaded fonts (#5483). The scan changes the generation a moment before it counts as done, so + // a read in between sees the old list under the new generation: wait for it to end first. + FontDb::global().family_list(); app.run("file.new", json!({})).unwrap(); let fonts = |app: &VectorcraftApp| -> Vec { menus::menu_entries(app).into_iter().filter(|e| e.path == ["Type", "Font"]).map(|e| e.label).collect() diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 40d81c4e8d..595d12b51f 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -31,6 +31,17 @@ pub(super) struct Board { uncounted: bool, /// The app's writes empty the clipboard and then fail. partial: bool, + /// With `partial`, the formats other than text are written before the failure. + partial_keeps_others: bool, + /// Another app copies this while the app's write tries to open the clipboard, and the write + /// fails. + race: Option, + /// With `race`, the other app's copy also carries the app's formats other than text. + race_with_our_formats: bool, + /// The app's writes fail and change nothing; reads still work. + refuse: bool, + /// `has` finds text only, as the Linux clipboard's does. + text_only_has: bool, } struct Fake(Rc>); @@ -49,11 +60,20 @@ impl Fake { impl SystemClipboard for Fake { fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { let mut b = self.0.borrow_mut(); - if b.locked { + if b.locked || b.refuse { + return Err("another app has the clipboard open".into()); + } + if let Some(copied) = b.race.take() { + b.flavours = vec![copied]; + if b.race_with_our_formats { + b.flavours.extend(flavours.iter().filter(|f| f.mime != TEXT).cloned()); + } + b.ours = false; + b.changes += 1; return Err("another app has the clipboard open".into()); } if b.partial { - b.flavours.clear(); + b.flavours = if b.partial_keeps_others { flavours.iter().filter(|f| f.mime != TEXT).cloned().collect() } else { vec![] }; b.changes += 1; return Err("a format could not be written".into()); } @@ -71,6 +91,9 @@ impl SystemClipboard for Fake { self.find(mimes) } fn has(&mut self, mimes: &[&'static str]) -> bool { + if self.0.borrow().text_only_has { + return mimes.contains(&TEXT) && self.find(&[TEXT]).is_some(); + } self.find(mimes).is_some() } } @@ -103,9 +126,31 @@ pub(super) fn without_change_count(board: &Rc>) { board.borrow_mut().uncounted = true; } -/// The app's writes empty the clipboard and then fail, as when one format can't be written. -pub(super) fn fail_writes_after_emptying(board: &Rc>) { - board.borrow_mut().partial = true; +/// The fake clipboard's `has` finds text only, as the Linux clipboard's does. +pub(super) fn has_text_only(board: &Rc>) { + board.borrow_mut().text_only_has = true; +} + +/// The app's writes fail (`true`) and change nothing, while reads still work. +pub(super) fn refuse_writes(board: &Rc>, refuse: bool) { + board.borrow_mut().refuse = refuse; +} + +/// The app's writes empty the clipboard and then fail, as when one format can't be written; with +/// `keep_others`, the formats other than text are written. +pub(super) fn fail_writes_after_emptying(board: &Rc>, keep_others: bool) { + let mut b = board.borrow_mut(); + b.partial = true; + b.partial_keeps_others = keep_others; +} + +/// Another app copies `data` of type `mime` while the app's next write tries to open the +/// clipboard, and that write fails. With `our_formats`, its copy also carries the app's formats +/// other than text. +pub(super) fn copy_during_next_write(board: &Rc>, mime: &'static str, data: Vec, our_formats: bool) { + let mut b = board.borrow_mut(); + b.race = Some(Flavour { mime, data }); + b.race_with_our_formats = our_formats; } pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e0c8505a22..c75c85b765 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1162,6 +1162,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). + To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them. Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text (unless `outlineText` already asks for every type object as outlines): a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`). A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).