diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 84e4a3aee..fe957b22b 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -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); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 3701d11a9..9256d3473 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -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); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 39fb2f214..91cf653cd 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -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), diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 06d0f9119..731e2537c 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -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)> = 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; diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 7d58e1c79..75853337a 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -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"); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 433cf02ce..ebd7f1fb9 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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 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!("", 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(""); } - 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(""); diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 79eebd350..a3c586096 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -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(); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index 649aadf47..d86403749 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -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 `` Elements writes the shrunk size too. + let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() }); + let size = attr(&fewer[fewer.find(" /// `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; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b3d1294a3..85c6eaff8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 83286c37b..69fd6b07f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 `` 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/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`).