mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 00:10:11 +00:00
Merge pull request #5720 from dotCooCoo/fix/open-export-avoidable-work
SVG export: type with Mojikumi and no Japanese characters is laid out once
This commit is contained in:
@@ -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<usize> = 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<usize, (f64, f64)> {
|
||||
// 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<usize, (f64, f64)> = 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<f64>) -> Option<(f64, f64)> {
|
||||
};
|
||||
Some((x, first.y))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -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<usize, (f64, f64)> {
|
||||
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<usize, (f64, f64)> = 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<TextObject>) -> (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</tspan>"), "{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}");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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
|
||||
|
||||
@@ -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 `<tspan>` 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 `<tspan>`, 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).
|
||||
|
||||
Reference in New Issue
Block a user