mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #396: SVG type opens at the size it draws at
SVG import kept a <text>'s nominal font-size and left the scale of the transforms and viewBox above it in the type's transform, so the Character panel showed one size and the canvas drew another (7 pt under scale(3) drew at 21 pt). The importer now moves that scale into the type: the scale across the baseline (the mean scale for type on a path) multiplies the size, leading, baseline shift, character stroke weights and dashes and the text path, and the transform keeps only the rest (a stretch along the baseline, a skew, a rotation or a reflection). The type draws exactly as before. Paints resolve in the grown text space, non-scaling strokes and the stroke under the characters are worked out before the fold, the factor is rounded to 6 significant digits (usvg's transforms are single precision) and never pushes sizes past the Character panel's 0.1–1296 pt (the rest stays in the transform). Tests: a group's, the text's own and a viewBox scale; rotated and scaled text equal to the same text written at twice the size; stretch and skew; type on a scaled path; the size limits; gradients on scaled text; SVG export → import of scaled type (stable over two round trips). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
649656b092
commit
ffed92aba5
7 files changed
+226
-12
No files matched your search
+2
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374) and PDF/`.ai` import keeping strokes live and hidden and nested layers (#372) and per-corner Live Corners (#381); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381) and SVG type opening at the size it draws at (#396); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -145,7 +145,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
|
||||
@@ -677,9 +677,12 @@ impl Importer {
|
||||
return Some(vec![]);
|
||||
}
|
||||
let text::PendingText { name, mut obj, servers, paints, non_scaling, under, .. } = self.slots.texts.get(i)?.clone();
|
||||
// The scale the type draws at becomes its size (last, below): text space grows by `grow`.
|
||||
let xf = ts * obj.xf;
|
||||
let grow = text::size_scale(&obj, xf);
|
||||
// usvg resolved the `url(#…)` paints (the paths after the main one) in the element's user
|
||||
// space; runs paint in text space.
|
||||
let to_text = obj.xf.inverse();
|
||||
let to_text = Affine::scale(grow) * obj.xf.inverse();
|
||||
let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) {
|
||||
Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| {
|
||||
let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m));
|
||||
@@ -697,7 +700,7 @@ impl Importer {
|
||||
run.style.stroke = server(k);
|
||||
}
|
||||
}
|
||||
obj.xf = ts * obj.xf;
|
||||
obj.xf = xf;
|
||||
if non_scaling && !vector_effect::unscale_text(&mut obj, self.screen) {
|
||||
let label = if name.is_empty() || made_up(&name) { "a text".to_string() } else { format!("text '{name}'") };
|
||||
self.warn(format!("non-scaling stroke on {label} approximated: the text is stretched or skewed"));
|
||||
@@ -712,6 +715,7 @@ impl Importer {
|
||||
ap.set_contents_at(1);
|
||||
ap
|
||||
});
|
||||
text::fold_scale(&mut obj, grow);
|
||||
let mut n = self.named(&name, NodeKind::Text(Box::new(obj)));
|
||||
if let Some(ap) = under {
|
||||
n.appearance = ap;
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
//! line spacing), other vertical moves become baseline shift, horizontal moves become kerning (an
|
||||
//! absolute `x` is measured against the laid-out text), and rotations become character rotation.
|
||||
//! `<textPath>` becomes type on a path; vertical `writing-mode` becomes type on a vertical path.
|
||||
//!
|
||||
//! Type takes the size it draws at: the scale of the transforms and `viewBox` above a `<text>`
|
||||
//! moves into its sizes ([`size_scale`], [`fold_scale`]), and the rest (a stretch, skew,
|
||||
//! rotation or reflection) stays its transform.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::ops::Range;
|
||||
use std::ops::{Range, RangeInclusive};
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -653,6 +657,52 @@ fn stroke_under(cells: &mut [Cell], label: &str, warnings: &mut Vec<String>) ->
|
||||
first
|
||||
}
|
||||
|
||||
/// The type sizes the Character panel takes, in points.
|
||||
const SIZES: RangeInclusive<f64> = 0.1..=1296.0;
|
||||
|
||||
/// How much of `xf` (text space → document) [`fold_scale`] makes the type's size: the scale
|
||||
/// across the baseline for point type (a stretch along it, a skew, a rotation and a reflection
|
||||
/// stay in the transform), the mean scale for type on a path, whose baseline turns. Not past the
|
||||
/// Character panel's sizes (the rest stays in the transform); 1 when `xf` collapses the text.
|
||||
pub(super) fn size_scale(t: &TextObject, xf: Affine) -> f64 {
|
||||
let [a, b, ..] = xf.as_coeffs();
|
||||
let det = xf.determinant().abs();
|
||||
let k = match t.kind {
|
||||
TextKind::OnPath { .. } => det.sqrt(),
|
||||
_ => det / a.hypot(b),
|
||||
};
|
||||
// To 6 significant digits: usvg's transforms are single precision, and `rotate(30) scale(2)`
|
||||
// makes 7 pt type 14 pt, not 13.9999998 pt (the type draws the same either way).
|
||||
let p = 10f64.powi((5.0 - k.log10().floor()) as i32);
|
||||
let k = (k * p).round() / p;
|
||||
if !(k.is_finite() && k > 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
let (lo, hi) = t.runs.iter().map(|r| r.style.size).fold((f64::INFINITY, 0.0f64), |(lo, hi), s| (lo.min(s), hi.max(s)));
|
||||
// Sizes already outside the range don't move further out.
|
||||
if k > 1.0 { k.min((SIZES.end() / hi).max(1.0)) } else { k.max((SIZES.start() / lo).min(1.0)) }
|
||||
}
|
||||
|
||||
/// Make text space `k` times larger and the transform `1 / k` times smaller, so the type draws
|
||||
/// the same at `k` times its size (and leading, baseline shift, character strokes and path): the
|
||||
/// size the Character panel shows is the size it draws at.
|
||||
pub(super) fn fold_scale(t: &mut TextObject, k: f64) {
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
st.size *= k;
|
||||
st.leading = st.leading.map(|l| l * k);
|
||||
st.baseline_shift *= k;
|
||||
}
|
||||
t.scale_char_strokes(k);
|
||||
let up = Affine::scale(k);
|
||||
match &mut t.kind {
|
||||
TextKind::OnPath { path, .. } | TextKind::Area { frame: path } => path.transform(up),
|
||||
TextKind::Point => {}
|
||||
}
|
||||
t.cached_bounds = t.cached_bounds.map(|b| up.transform_rect_bbox(b));
|
||||
t.xf *= Affine::scale(1.0 / k);
|
||||
}
|
||||
|
||||
/// `v`, or 0 when far-off positions added up past f64's range.
|
||||
fn finite(v: f64) -> f64 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
|
||||
@@ -385,3 +385,136 @@ fn far_off_positions_stay_finite() {
|
||||
assert!(r.style.baseline_shift.is_finite() && r.style.kerning.is_none_or(f64::is_finite) && r.style.rotation.is_finite(), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawn bounds of the laid-out text in the document.
|
||||
fn drawn(t: &TextObject) -> Rect {
|
||||
t.xf.transform_rect_bbox(layout(FontDb::global(), t).bounds)
|
||||
}
|
||||
|
||||
/// The linear part of a transform.
|
||||
fn linear(t: &TextObject) -> [f64; 4] {
|
||||
let [a, b, c, d, ..] = t.xf.as_coeffs();
|
||||
[a, b, c, d]
|
||||
}
|
||||
|
||||
fn close4(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
// usvg's transforms are single precision.
|
||||
a.iter().zip(b).all(|(a, b)| close(*a, b, 1e-6))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_above_text_becomes_its_size() {
|
||||
// #396: a group's or the text's own scale is the size the type shows and draws at.
|
||||
let d = open(
|
||||
r#"<text x="50" y="100" font-size="7">HHHH Hamburg</text>
|
||||
<g transform="scale(3)"><text x="16" y="60" font-size="7">HHHH Hamburg</text></g>
|
||||
<text x="50" y="400" font-size="7" transform="scale(2)">HHHH Hamburg</text>"#,
|
||||
);
|
||||
let t = texts(&d);
|
||||
let sizes: Vec<f64> = t.iter().map(|t| t.first_style().size).collect();
|
||||
assert!(close(sizes[0], 7.0, 1e-9) && close(sizes[1], 21.0, 1e-9) && close(sizes[2], 14.0, 1e-9), "{sizes:?}");
|
||||
for t in &t {
|
||||
assert!(close4(linear(t), [1.0, 0.0, 0.0, 1.0]), "{:?}", t.xf);
|
||||
}
|
||||
assert!(t[1].xf.translation().to_point().distance(Point::new(48.0, 180.0)) < 1e-9, "{:?}", t[1].xf);
|
||||
assert!(t[2].xf.translation().to_point().distance(Point::new(100.0, 800.0)) < 1e-9, "{:?}", t[2].xf);
|
||||
// Drawn as large as their sizes say.
|
||||
let h: Vec<f64> = t.iter().map(|t| drawn(t).height() / t.first_style().size).collect();
|
||||
assert!(close(h[1], h[0], 1e-6) && close(h[2], h[0], 1e-6), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewbox_scale_becomes_the_size() {
|
||||
let d = import(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="306" height="396" viewBox="0 0 612 792"><text x="50" y="100" font-size="7">HH</text></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let t = only_text(&d);
|
||||
assert!(close(t.first_style().size, 3.5, 1e-9), "{}", t.first_style().size);
|
||||
assert!(close4(linear(t), [1.0, 0.0, 0.0, 1.0]), "{:?}", t.xf);
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(25.0, 50.0)) < 1e-9, "{:?}", t.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_scale_scales_every_length_and_keeps_the_rotation() {
|
||||
// The same text written at twice the size: the scale moves into size, leading, baseline
|
||||
// shift and the character strokes; spacing in ems stays.
|
||||
let scaled = open(
|
||||
r#"<text transform="translate(100 50) rotate(30) scale(2)" x="5" y="10" font-size="7" letter-spacing="1" word-spacing="2" stroke="red" stroke-width="0.5" stroke-dasharray="1 2">Ab <tspan baseline-shift="3">c</tspan><tspan x="5" dy="12">Two</tspan></text>"#,
|
||||
);
|
||||
let written = open(
|
||||
r#"<text transform="translate(100 50) rotate(30)" x="10" y="20" font-size="14" letter-spacing="2" word-spacing="4" stroke="red" stroke-width="1" stroke-dasharray="2 4">Ab <tspan baseline-shift="6">c</tspan><tspan x="10" dy="24">Two</tspan></text>"#,
|
||||
);
|
||||
let (s, w) = (only_text(&scaled), only_text(&written));
|
||||
assert_eq!(s.runs, w.runs);
|
||||
assert!(s.runs.iter().any(|r| r.style.leading == Some(24.0) && r.text.contains("Two")), "{:?}", s.runs);
|
||||
assert!(close(s.xf.determinant(), 1.0, 1e-6), "{:?}", s.xf);
|
||||
let r = 30f64.to_radians();
|
||||
assert!(close4(linear(s), [r.cos(), r.sin(), -r.sin(), r.cos()]), "{:?}", s.xf);
|
||||
for (a, b) in glyph_origins(s).iter().zip(glyph_origins(w)) {
|
||||
assert!(a.distance(b) < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretch_or_skew_stays_the_transform() {
|
||||
// The scale across the baseline is the size; a stretch along it or a skew stays.
|
||||
let d = open(
|
||||
r#"<text x="10" y="20" font-size="7" transform="scale(3 1)">HH</text>
|
||||
<text x="10" y="20" font-size="7" transform="scale(1 3)">HH</text>
|
||||
<text x="10" y="20" font-size="7" transform="skewX(20)">HH</text>"#,
|
||||
);
|
||||
let t = texts(&d);
|
||||
let sizes: Vec<f64> = t.iter().map(|t| t.first_style().size).collect();
|
||||
assert!(close(sizes[0], 7.0, 1e-9) && close(sizes[1], 21.0, 1e-9) && close(sizes[2], 7.0, 1e-9), "{sizes:?}");
|
||||
assert!(close4(linear(t[0]), [3.0, 0.0, 0.0, 1.0]), "{:?}", t[0].xf);
|
||||
assert!(close4(linear(t[1]), [1.0 / 3.0, 0.0, 0.0, 1.0]), "{:?}", t[1].xf);
|
||||
assert!(close4(linear(t[2]), [1.0, 0.0, 20f64.to_radians().tan(), 1.0]), "{:?}", t[2].xf);
|
||||
// `scale(1 3)` draws three times as tall as `scale(3 1)`, a third as wide.
|
||||
let (wide, tall) = (drawn(t[0]), drawn(t[1]));
|
||||
assert!(close(tall.height(), 3.0 * wide.height(), 1e-6) && close(3.0 * tall.width(), wide.width(), 1e-6), "{wide:?} {tall:?}");
|
||||
assert!(t[1].xf.translation().to_point().distance(Point::new(10.0, 60.0)) < 1e-9, "{:?}", t[1].xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_on_a_scaled_path_takes_the_scale() {
|
||||
let d = open(
|
||||
r##"<g transform="scale(2)"><path id="p" d="M10 100 L110 100" fill="none"/>
|
||||
<text font-size="10"><textPath href="#p">Curve</textPath></text></g>"##,
|
||||
);
|
||||
let t = only_text(&d);
|
||||
assert!(close(t.first_style().size, 20.0, 1e-9), "{}", t.first_style().size);
|
||||
assert!(close(t.xf.determinant(), 1.0, 1e-6), "{:?}", t.xf);
|
||||
let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let b = t.xf.transform_rect_bbox(path.bounds().unwrap());
|
||||
assert!(close(b.x0, 20.0, 1e-9) && close(b.x1, 220.0, 1e-9) && close(b.y0, 200.0, 1e-9), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folded_sizes_stay_in_the_character_panels_range() {
|
||||
// Past 1296 pt (or under 0.1 pt) the rest of the scale stays the transform.
|
||||
let d = open(
|
||||
r#"<g transform="scale(20)"><text y="10" font-size="100">Big</text></g>
|
||||
<g transform="scale(0.001)"><text y="10" font-size="7">Small</text></g>
|
||||
<text y="10" font-size="2000">Huge</text>"#,
|
||||
);
|
||||
let t = texts(&d);
|
||||
let size = |i: usize| t[i].first_style().size;
|
||||
assert!(close(size(0), 1296.0, 1e-9) && close(size(0) * linear(t[0])[0], 2000.0, 1e-9), "{:?}", t[0].xf);
|
||||
assert!(close(size(1), 0.1, 1e-9) && close(size(1) * linear(t[1])[0], 0.007, 1e-9), "{:?}", t[1].xf);
|
||||
assert!(close(size(2), 2000.0, 1e-9) && close4(linear(t[2]), [1.0, 0.0, 0.0, 1.0]), "unscaled type keeps its size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_on_scaled_text_stay_in_place() {
|
||||
let d = open(
|
||||
r##"<defs><linearGradient id="user" gradientUnits="userSpaceOnUse" x1="20" y1="0" x2="120" y2="0"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs>
|
||||
<g transform="scale(2)"><text x="20" y="50" font-size="20" fill="url(#user)">Gradient</text></g>"##,
|
||||
);
|
||||
let t = only_text(&d);
|
||||
assert!(close(t.first_style().size, 40.0, 1e-9));
|
||||
let Paint::Gradient(g) = &t.runs[0].style.fill else { panic!("{:?}", t.runs[0].style.fill) };
|
||||
let geom = g.geom.unwrap();
|
||||
let (start, end) = (t.xf * geom.start, t.xf * geom.end);
|
||||
assert!(start.distance(Point::new(40.0, 0.0)) < 1e-6 && end.distance(Point::new(240.0, 0.0)) < 1e-6, "{start:?} {end:?}");
|
||||
}
|
||||
@@ -115,11 +115,10 @@ fn text_lengths_in_physical_units() {
|
||||
assert!(close(o.x, 72.0) && close(o.y, 144.0), "{o:?}");
|
||||
let st = t.first_style();
|
||||
assert!(close(st.tracking, 100.0), "{}", st.tracking);
|
||||
// User units of a physical root are CSS pixels (0.75 pt): the text carries that scale.
|
||||
let scale = t.xf.as_coeffs()[0];
|
||||
assert!(close(scale, 0.75), "{:?}", t.xf);
|
||||
assert!(close(st.stroke_width * scale, 1.0), "{}", st.stroke_width);
|
||||
assert!(close(st.size * scale, 15.0), "{}", st.size);
|
||||
// User units of a physical root are CSS pixels (0.75 pt): that scale moves into the type.
|
||||
assert!(close(t.xf.as_coeffs()[0], 1.0), "{:?}", t.xf);
|
||||
assert!(close(st.stroke_width, 1.0), "{}", st.stroke_width);
|
||||
assert!(close(st.size, 15.0), "{}", st.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,9 +84,10 @@ fn type_keeps_its_non_scaling_stroke_width() {
|
||||
let (d, warnings) = import_with_report(svg).unwrap();
|
||||
let t = text(&d);
|
||||
let st = &t.runs[0].style;
|
||||
// Text space is scaled 2×: half the width there is the width on screen.
|
||||
// The scale became the type's size: the stroke and its dashes keep their width on screen.
|
||||
assert!(close(st.size, 40.0) && close(t.xf.determinant(), 1.0), "{} {:?}", st.size, t.xf);
|
||||
assert!(close(st.stroke_width * t.xf.determinant().sqrt(), 3.0), "{} {:?}", st.stroke_width, t.xf);
|
||||
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0]));
|
||||
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![4.0, 2.0]));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
|
||||
let (_, warnings) = import_with_report(&svg.replace("scale(2)", "scale(2 1)")).unwrap();
|
||||
|
||||
@@ -386,6 +386,33 @@ fn text_roundtrip_and_escaping() {
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(20.0, 50.0)) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_text_roundtrips_at_the_size_it_draws() {
|
||||
// Type scaled with its object (its transform scales it) comes back at the size it draws at,
|
||||
// drawn the same, and stays so on the next round trip; a stretch stays the transform.
|
||||
let at = Affine::translate((20.0, 50.0));
|
||||
let stretch = at * Affine::rotate(0.5) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
for (xf, size) in [(at * Affine::scale(2.0), 18.0), (at * Affine::rotate(0.5) * Affine::scale(3.0), 27.0), (stretch, 9.0)] {
|
||||
let mut t = TextObject::point(Point::ZERO, "Scaled\ntype", CharStyle { size: 9.0, leading: Some(11.0), ..CharStyle::default() });
|
||||
t.xf = xf;
|
||||
let drawn = |t: &TextObject| t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds);
|
||||
let before = drawn(&t);
|
||||
let id = Document::new(200.0, 200.0).alloc_id();
|
||||
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
|
||||
let once = roundtrip(&d);
|
||||
let NodeKind::Text(t) = &art(&once)[0].kind else { panic!() };
|
||||
// Within the precision of the exported matrix (3 decimals); the second line carries the
|
||||
// leading.
|
||||
let near = |a: f64, b: f64| (a - b).abs() < b * 1e-3;
|
||||
let st = &t.runs.last().unwrap().style;
|
||||
assert!(near(st.size, size) && near(st.leading.unwrap(), 11.0 * size / 9.0), "{xf:?}: {st:?}");
|
||||
assert!(close_rect(drawn(t), before, 0.05), "{xf:?}: {:?} {before:?}", drawn(t));
|
||||
let twice = roundtrip(&once);
|
||||
let NodeKind::Text(t2) = &art(&twice)[0].kind else { panic!() };
|
||||
assert!(near(t2.first_style().size, size) && close_rect(drawn(t2), before, 0.05), "{xf:?}: {:?} {:?}", t2.runs, drawn(t2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_path_roundtrip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
Reference in new issue
Block a user