Export: area type that Shrink Text to Fit scales down is written at the size it draws at

Shrink Text to Fit scales area type down at layout time, and the canvas,
PDF export and the exports that write type as outlines draw it at that
size. SVG export wrote the live text at its stored font size, letter
spacing and baseline shift, so a viewer drew it larger than the canvas
does. Legacy .ai saves wrote the stored size, leading and baseline shift
into the text objects, DXF export took the TEXT heights from the stored
size, and the CSS Properties panel gave the stored font size, letter
spacing and line height.

The text crate's scaled, which the layout scales shrunk text with, is
now public. SVG export, legacy .ai saves and the CSS Properties panel
take the type's styles from it at the layout's fit scale, and DXF
export multiplies the TEXT heights by that scale. Positions and
textLength in SVG still come from the layout.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-21 16:55:16 +07:00
committed by bflatastic
parent b1abc73e16
commit 68d8a9c755
11 changed files with 138 additions and 6 deletions
+2 -1
View File
@@ -862,7 +862,8 @@ impl<'a> Scene<'a> {
let Some(rgb) = self.paint_rgb(paint) else { continue };
let style = self.style(rgb, 1.0, None, None);
let text_style = self.text_style(&cs.font_family);
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
// The height is that of the size the layout draws: Shrink Text to Fit scales the size by `layout.fit_scale`.
let height = cs.size * layout.fit_scale * cs.v_scale / 100.0 * CAP_HEIGHT * across;
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
let p = self.pt(t.xf * origin);
+21
View File
@@ -456,6 +456,27 @@ fn type_is_text_or_outlines() {
}
}
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
#[test]
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
let mut d = text_doc("Plan A, plan B and plan C", 20.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut fit = 1.0;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 30.0)) };
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
fit = lay.fit_scale;
}
let out = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&out);
assert!(!e.is_empty() && e.iter().all(|e| kind(e) == "TEXT"), "{:?}", kinds(&out));
for e in &e {
assert!((all(e, 40)[0] - 14.0 * fit).abs() < 1e-6, "the cap height of {} pt type: {:?}", 20.0 * fit, all(e, 40));
}
}
#[test]
fn text_outside_ascii_is_escaped_before_2007() {
let d = text_doc("Größe", 12.0);
+6
View File
@@ -1145,6 +1145,12 @@ impl<'a> Writer<'a> {
if lost_attributes(t) {
self.warn(TYPE_ATTRIBUTES);
}
// Area type that Shrink Text to Fit scales down is written in the styles it draws in.
let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. }))
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale)
.filter(|f| *f != 1.0)
.map(|f| vectorcraft_text::scaled(t, f));
let t = fitted.as_ref().unwrap_or(t);
let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let (kind, path, start) = match &t.kind {
TextKind::Point => (0, None, 0.0),
+28
View File
@@ -993,6 +993,34 @@ fn type_reads_back_as_text() {
assert!((b.x0 - 150.0).abs() < 1e-3 && (b.y1 - 190.0).abs() < 1e-3, "{b:?}");
}
/// Area type that Shrink Text to Fit scales down is written at the sizes, leading and baseline
/// shifts it draws at, so it reads back fitting its frame.
#[test]
fn shrunk_area_type_is_written_at_the_size_it_draws_at() {
use vectorcraft_doc::{TextKind, TextObject, TextRun};
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area type that is too long ", style(14.0, Paint::solid(Color::BLACK)));
let raised = vectorcraft_doc::CharStyle { baseline_shift: 3.0, leading: Some(20.0), ..style(14.0, red()) };
area.runs.push(TextRun::new("for its frame shrinks", raised));
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)) };
area.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
area.xf = Affine::translate((20.0, 80.0));
let fit = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &area).fit_scale;
assert!(fit < 0.9, "the text is shrunk to fit: {fit}");
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(area)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
let imported = crate::import(&write(&doc).bytes).unwrap();
let found = texts(&imported.document);
let (_, back) = found.first().expect("the area type reads back");
let sizes: Vec<(f64, f64, Option<f64>)> = back.runs.iter().map(|r| (r.style.size, r.style.baseline_shift, r.style.leading)).collect();
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert!((sizes[0].0 - 14.0 * fit).abs() < 1e-3 && (sizes[1].0 - 14.0 * fit).abs() < 1e-3, "{sizes:?} at {fit}");
assert!((sizes[1].1 - 3.0 * fit).abs() < 1e-3, "{sizes:?} at {fit}");
assert!(sizes[1].2.is_some_and(|l| (l - 20.0 * fit).abs() < 1e-3), "{sizes:?} at {fit}");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).overflow, "it fits its frame as written");
}
#[test]
fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() {
use vectorcraft_doc::TextObject;
+6
View File
@@ -572,6 +572,12 @@ impl Rules<'_> {
if n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) {
art.unsupported("fills or strokes on the type object");
}
// Area type that Shrink Text to Fit scales down is written in the styles it draws in.
let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. }))
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale)
.filter(|f| *f != 1.0)
.map(|f| vectorcraft_text::scaled(t, f));
let t = fitted.as_ref().unwrap_or(t);
let st = t.first_style();
if t.runs.iter().any(|r| r.style != st) {
self.warn("type with several character styles is written in its first one");
+6 -3
View File
@@ -1899,6 +1899,9 @@ impl Writer<'_> {
}
self.note_fonts(t, &lay);
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
// `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts.
let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale));
let drawn = fitted.as_ref().unwrap_or(t);
// Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its
// stop, and so does the text after an inline graphic: lines with tabs, and text with
// inline graphics, are never anchored.
@@ -1919,7 +1922,7 @@ impl Writer<'_> {
let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten();
// The <text> element's user space is text space.
let space = (lay.bounds, Affine::IDENTITY);
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: uniform.is_some() };
let base = TextBase { props: self.char_props(&drawn.first_style(), space), space, anchored: uniform.is_some() };
let mut props = base.props.clone();
if let Some((a, _)) = uniform {
props.push(("text-anchor", a.into()));
@@ -1944,10 +1947,10 @@ impl Writer<'_> {
Some((a, _)) => {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\" text-anchor=\"{a}\">", self.num(start.0), self.num(start.1)));
let inner = TextBase { anchored: true, ..base.clone() };
self.text_line(&mut s, t, &line.segs, start, &inner, false);
self.text_line(&mut s, drawn, &line.segs, start, &inner, false);
s.push_str("</tspan>");
}
None => self.text_line(&mut s, t, &line.segs, start, &base, true),
None => self.text_line(&mut s, drawn, &line.segs, start, &base, true),
}
}
s.push_str("</text>");
+22
View File
@@ -175,6 +175,28 @@ fn type_gives_font_properties() {
assert!(prop(&r, "background-color").is_none() && r.unsupported.is_none());
}
/// Area type that Shrink Text to Fit scales down gives the size, spacing and leading it draws at.
#[test]
fn shrunk_area_type_gives_the_size_it_draws_at() {
let mut d = doc();
let st = CharStyle { size: 18.0, tracking: 50.0, leading: Some(24.0), fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut n = text(&mut d, "Area type that is too long for its frame", st);
let mut fit = 1.0;
if let NodeKind::Text(t) = &mut n.kind {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)) };
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
fit = lay.fit_scale;
}
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
let px = |v: f64| format!("{}px", vectorcraft_svg::fmt_num(v, 3));
assert_eq!(prop(&r, "font-size"), Some(px(18.0 * fit).as_str()), "{r:?}");
assert_eq!(prop(&r, "letter-spacing"), Some(px(0.9 * fit).as_str()), "{r:?}");
assert_eq!(prop(&r, "line-height"), Some(px(24.0 * fit).as_str()), "{r:?}");
}
#[test]
fn shared_properties_read_as_svg_export_writes_them() {
let mut d = doc();
+42
View File
@@ -353,6 +353,48 @@ fn justified_japanese_lines_keep_the_gaps_between_their_characters() {
}
}
/// Area type that Shrink Text to Fit scales down to fit its frame is written at the size the
/// canvas draws it at: sizes, tracking, manual kerning and baseline shifts, on ragged, anchored
/// and justified lines.
#[test]
fn shrunk_area_type_is_written_at_the_size_it_draws_at() {
let tracked = CharStyle { tracking: 80.0, ..style(12.0) };
let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) };
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) };
for justify in [Justify::Left, Justify::Center, Justify::JustifyLeft, Justify::JustifyAll] {
// Tracked and kerned letters are inside words: usvg also spaces a line's last letter, which
// moves an anchored line ending in one.
let mut runs = vec![
run("plain words ", style(12.0)),
run("trac", tracked.clone()),
run("ked ", style(12.0)),
run("ker", kerned.clone()),
run("ned ", style(12.0)),
run("up ", shifted.clone()),
run("big ", style(18.0)),
];
// usvg anchors a centered line by its width before `textLength` stretches it.
if justify != Justify::Center {
runs.push(run("wide ", wide.clone()));
}
runs.push(run("and more words at the end", style(12.0)));
let mut d = area_doc(runs, 90.0, justify);
let id = d.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 40.0)) };
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &the_text(&d));
assert!(!lay.overflow && lay.fit_scale < 0.8, "{justify:?}: the text is shrunk to fit ({})", lay.fit_scale);
assert_drawn_in_place(&d);
// Fewer `<tspan>` Elements writes the shrunk size too.
let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() });
let size = attr(&fewer[fewer.find("<text").unwrap()..], "font-size").unwrap();
assert!((size - 12.0 * lay.fit_scale).abs() < 1e-3, "{justify:?}: {fewer}");
}
}
/// Japanese composition (Mojikumi) closes up consecutive punctuation and adds a quarter em between
/// Japanese and Latin letters; live text keeps that spacing, on anchored lines too.
#[test]
+3 -1
View File
@@ -326,7 +326,9 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
/// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own
/// shift included (auto leading and the art's size follow the size; paragraph spacing stays).
fn scaled(t: &TextObject, f: f64) -> TextObject {
/// Shrink Text to Fit lays `t` out as `scaled(t, f)`, where `f` is the layout's
/// [`TextLayout::fit_scale`], so the result holds the styles the text is drawn in.
pub fn scaled(t: &TextObject, f: f64) -> TextObject {
let mut s = t.clone();
for r in &mut s.runs {
r.style.size *= f;
+1 -1
View File
@@ -36,7 +36,7 @@ pub use fontdb::{
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
+1
View File
@@ -753,6 +753,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, that type is left out only when, for each piece of it, one of those text objects has the piece's Latin letters and digits in its text, in capitals or not; other such type is kept as the page's other type of no text object is.
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/text_layout.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.
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).
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is (`crates/eps/src/import/tests_native.rs`).
A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).