M4.27: area type and rich text in SVG export

Every laid-out line of type is written at its own position (wraps,
indents, tabs, hyphenation and all caps as on the canvas): a positioned
tspan per style run and, on justified lines, per word. Centred and
right-aligned lines are anchored at their centre or right end, so they
stay aligned in a viewer whose font differs. Leading comes from the
layout's baselines, baseline shift is written as dy, horizontal scale as
textLength, vertical scale as the size, tracking and manual kerning as
letter-spacing. The fewerTspans option (engine and dialog) writes one
positioned tspan per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 18:47:08 +07:00
parent 4a3859c69b
commit 1982ecf98d
9 changed files with 402 additions and 118 deletions

View File

@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),

View File

@ -55,6 +55,12 @@ pub const OPTIONS: &[FormatOption] = &[
description: "embed the native document in <metadata> so VectorCraft reopens the SVG with nothing lost",
},
FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write <metadata> with the title and format (Dublin Core)" },
FormatOption {
name: "fewerTspans",
ty: "boolean",
default: "false",
description: "type: one positioned <tspan> per line instead of one per style run, tab and justified word (smaller; viewers space the line themselves)",
},
FormatOption {
name: "svg",
ty: "object",

View File

@ -182,3 +182,13 @@ fn formats_list_the_svg_options_with_their_defaults() {
assert!(svg["options"].get(k).is_some(), "{k}");
}
}
#[test]
fn fewer_tspans_reaches_the_writer() {
let mut s = Session::new();
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="10" y="40" font-size="12">plain <tspan fill="#ff0000">red</tspan> plain</text></svg>"##;
s.execute("document.open", &json!({"name": "two-runs.svg", "dataBase64": vectorcraft_format::base64_encode(src.as_bytes())})).unwrap();
let placed = |svg: &str| svg.matches("<tspan x=").count();
assert_eq!(placed(&svg(&mut s, json!({}))), 3, "a tspan per style run");
assert_eq!(placed(&svg(&mut s, json!({"fewerTspans": true}))), 1, "a tspan per line");
}

View File

@ -14,14 +14,6 @@ use crate::{EDITING_NS, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output
type Props = Vec<(&'static str, String)>;
/// One laid-out line of area type: baseline anchor (text space) and its pieces of each run.
struct AreaLine<'t> {
x: f64,
y: f64,
pieces: Vec<(usize, &'t str)>,
hyphenated: bool,
}
pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
// Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result.
let baked = vectorcraft_effects::bake_document(doc);
@ -301,7 +293,7 @@ impl Writer<'_> {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute.
let (style, attrs): (Vec<_>, Vec<_>) =
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings"));
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-kerning"));
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
if !style.is_empty() {
s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style))));
@ -1136,7 +1128,8 @@ impl Writer<'_> {
let fam =
if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() };
p.push(("font-family", fam));
p.push(("font-size", self.num(st.size)));
// Vertical scale is the font size; horizontal scale stretches it (`textLength`).
p.push(("font-size", self.num(st.size * st.v_scale / 100.0)));
let fs = st.font_style.to_ascii_lowercase();
if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") {
p.push(("font-weight", "bold".into()));
@ -1158,8 +1151,14 @@ impl Writer<'_> {
}
self.stroke_props(&layer, layer.width, None, &mut p);
}
if st.tracking != 0.0 {
p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size)));
// Tracking and manual kerning both add space after every character.
let spacing = st.tracking + st.kerning.unwrap_or(0.0);
if spacing != 0.0 {
p.push(("letter-spacing", self.num(spacing / 1000.0 * st.size)));
}
if st.kerning.is_some() {
// Manual kerning replaces the font's pair kerning.
p.push(("font-kerning", "none".into()));
}
if !st.features.is_empty() {
let v: Vec<String> = st
@ -1242,21 +1241,106 @@ impl Writer<'_> {
self.line("</g>");
}
/// Point and area type, every laid-out line at its own position: wraps, indents, tabs and
/// spacing come out as on the canvas. Centred and right-aligned lines are anchored
/// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font
/// differs; other lines are placed where each style run (and, justified, each word) starts.
fn text(&mut self, n: &Node, t: &TextObject) {
let first = t.first_style();
let lead = first.effective_leading();
if let TextKind::OnPath { path, start } = &t.kind {
return self.text_on_path(n, t, path, *start);
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
// A tab starts a new chunk at its stop: only lines without tabs can be anchored.
let anchor = match t.para.justify {
Justify::Center => Some(("middle", 0.5)),
Justify::Right => Some(("end", 1.0)),
_ => None,
}
.filter(|_| !lines.iter().flatten().any(|s| s.brk));
let base = self.char_props(&t.first_style());
let mut props = base.clone();
if let Some((a, _)) = anchor {
props.push(("text-anchor", a.into()));
}
props.extend(Self::node_props(n));
let id = self.id_attr(n);
let (m, on_path) = match &t.kind {
// Area type lines are positioned in text space (see `area_lines`).
TextKind::Point | TextKind::Area { .. } => (self.xf * t.xf, None),
TextKind::OnPath { path, start } => {
let pid = self.fresh_id("text-path");
let d = self.path_d(path, self.xf * t.xf);
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
(Affine::IDENTITY, Some((pid, *start)))
}
let a = self.attrs(&props);
let m = self.xf * t.xf;
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
let starts: Vec<Option<(f64, f64)>> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect();
// The `<text>` carries the first line's position too, so readers that place text by its
// own x/y start where the first line does.
let at = match starts.iter().flatten().next() {
Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)),
None => String::new(),
};
let mut props = self.char_props(&first);
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
for (line, start) in lines.iter().zip(starts) {
if let Some(start) = start {
self.text_line(&mut s, t, line, start, anchor.is_some(), &base);
}
}
s.push_str("</text>");
self.line(&s);
}
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
/// (only the first of an `anchored` line). Fewer tspans: one positioned `<tspan>` per line with
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), anchored: bool, base: &Props) {
let fewer = self.opts.fewer_tspans;
if fewer {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\">", self.num(start.0), self.num(start.1)));
}
let mut place = !fewer;
let mut first = true;
// The baseline shift the current text position carries.
let mut shift = 0.0;
for seg in line {
let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue };
let mut attrs = String::new();
if place {
let (x, y) = if first { start } else { (seg.x, seg.y) };
attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y)));
shift = 0.0;
}
first = false;
if st.baseline_shift != shift {
attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift)));
shift = st.baseline_shift;
}
// After a tab or a justified word space the next segment is placed.
place = seg.brk || (!fewer && !anchored);
if (st.h_scale - st.v_scale).abs() > 1e-9 {
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
}
if st.rotation != 0.0 {
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
}
let diff = run_diff(base, self.char_props(st));
attrs.push_str(&self.attrs(&diff));
let text = xml_escape(&seg.text);
if attrs.is_empty() && fewer {
s.push_str(&text);
} else {
s.push_str(&format!("<tspan{attrs}>{text}</tspan>"));
}
}
if fewer {
s.push_str("</tspan>");
}
}
/// Type on a path: a `<textPath>` along the path (a def).
fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) {
let id = self.id_attr(n);
let pid = self.fresh_id("text-path");
let d = self.path_d(path, self.xf * t.xf);
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
let base = self.char_props(&t.first_style());
let mut props = base.clone();
match t.para.justify {
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
@ -1264,100 +1348,16 @@ impl Writer<'_> {
}
props.extend(Self::node_props(n));
let a = self.attrs(&props);
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
// Area type: laid-out lines (the `<text>` carries the first line's position too, so readers
// that place text by its own x/y start where the first line does).
let lines = matches!(t.kind, TextKind::Area { .. }).then(|| self.area_lines(t));
let at = match lines.as_ref().and_then(|l| l.first()) {
Some(l) => format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)),
None => String::new(),
};
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
if let Some((pid, start)) = &on_path {
s.push_str(&format!("<textPath xlink:href=\"#{pid}\" startOffset=\"{}%\">", fmt_num(start * 100.0, 3)));
let offset = fmt_num(start * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &t.runs {
let diff = run_diff(&base, self.char_props(&run.style));
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_escape(&run.text.replace('\n', " "))));
}
let base = self.char_props(&first);
if let Some(lines) = lines {
for l in lines {
let last = l.pieces.len() - 1;
for (k, (ri, text)) in l.pieces.into_iter().enumerate() {
let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() };
let diff = run_diff(&base, self.char_props(&t.runs[ri].style));
let a = self.attrs(&diff);
let hy = if k == last && l.hyphenated { "-" } else { "" };
s.push_str(&format!("<tspan{pos}{a}>{}{hy}</tspan>", xml_escape(text)));
}
}
} else {
let mut line = 0usize;
let mut pending = false;
for run in &t.runs {
let diff = run_diff(&base, self.char_props(&run.style));
for (i, piece) in run.text.split('\n').enumerate() {
if i > 0 {
line += 1;
pending = true;
}
if piece.is_empty() {
continue;
}
let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() };
pending = false;
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{pos}{a}>{}</tspan>", xml_escape(piece)));
}
}
}
if on_path.is_some() {
s.push_str("</textPath>");
}
s.push_str("</text>");
s.push_str("</textPath></text>");
self.line(&s);
}
/// Area type as laid out. Soft line breaks are only known to the layout, so each laid-out line
/// is placed at its baseline (text space), at its left edge, centre or right edge to match the
/// `text-anchor` of the paragraph alignment.
fn area_lines<'t>(&self, t: &'t TextObject) -> Vec<AreaLine<'t>> {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut offsets = Vec::with_capacity(t.runs.len());
let mut off = 0;
for r in &t.runs {
offsets.push(off);
off += r.text.len();
}
let mut out = vec![];
for l in &lay.lines {
// Run pieces of this line (the paragraph's `\n` is outside `start..end`).
let mut pieces: Vec<(usize, &str)> = vec![];
for (ri, (r, &o)) in t.runs.iter().zip(&offsets).enumerate() {
let (a, b) = (l.start.max(o), l.end.min(o + r.text.len()));
if a < b {
pieces.push((ri, &r.text[a - o..b - o]));
}
}
// Spaces where the line wrapped don't belong to the line.
while let Some((_, p)) = pieces.last_mut() {
*p = p.trim_end();
if !p.is_empty() {
break;
}
pieces.pop();
}
if pieces.is_empty() {
continue;
}
let x = match t.para.justify {
Justify::Center | Justify::JustifyCenter => (l.x0 + l.x1) / 2.0,
Justify::Right | Justify::JustifyRight => l.x1,
_ => l.x0,
};
// A hyphenated break: the layout adds the hyphen as a glyph with no source text.
let hyphenated = l.glyph_end > l.glyph_start && lay.glyphs.get(l.glyph_end - 1).is_some_and(|g| g.len == 0);
out.push(AreaLine { x, y: l.baseline, pieces, hyphenated });
}
out
}
}
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
@ -1379,6 +1379,7 @@ fn initial_value(k: &str) -> Option<&'static str> {
"stroke-linejoin" => "miter",
"stroke-miterlimit" => "4",
"stroke-dashoffset" | "letter-spacing" => "0",
"font-kerning" => "auto",
_ => return None,
})
}
@ -1387,3 +1388,77 @@ fn initial_value(k: &str) -> Option<&'static str> {
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
}
/// Characters of one style on one line, written as one `<tspan>`.
struct Segment {
/// Index into `TextObject::runs`.
run: usize,
/// Where the segment starts (text space; `y` is the baseline).
x: f64,
y: f64,
text: String,
/// The laid-out width (tracking and justification included).
advance: f64,
/// The next segment needs its own position (after a tab, or a word space of a justified line).
brk: bool,
}
/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and,
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
/// caps are upper case.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
let plain = t.plain_text();
let split_words = !fewer && !matches!(t.para.justify, Justify::Left | Justify::Center | Justify::Right);
let mut lines = Vec::with_capacity(lay.lines.len());
for line in &lay.lines {
let mut segs: Vec<Segment> = Vec::new();
let mut cluster = None;
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default();
// Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line.
if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) {
while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) {
glyphs = rest;
}
}
for g in glyphs {
let Some(run) = t.runs.get(g.run) else { continue };
if g.len > 0 && cluster == Some(g.byte) {
// Another glyph of the same cluster.
if let Some(last) = segs.last_mut() {
last.advance += g.advance;
}
continue;
}
cluster = Some(g.byte);
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').collect();
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
match segs.last_mut() {
Some(last) if last.run == g.run && !last.brk => {
last.text.push_str(&piece);
last.advance += g.advance;
last.brk = brk;
}
_ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }),
}
}
segs.retain(|s| !s.text.is_empty());
if !segs.is_empty() {
lines.push(segs);
}
}
lines
}
/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1:
/// right end), that point of its width. `None` for an empty line.
fn line_start(line: &[Segment], anchor: Option<f64>) -> Option<(f64, f64)> {
let (first, last) = (line.first()?, line.last()?);
let x = match anchor {
Some(f) => first.x + f * (last.x + last.advance - first.x),
None => first.x,
};
Some((x, first.y))
}

View File

@ -134,6 +134,9 @@ pub struct ExportOptions {
pub preserve_editing: bool,
/// Write `<metadata>` with the document's Dublin Core title and format.
pub metadata: bool,
/// One positioned `<tspan>` per line of type instead of one per style run, tab stop and
/// justified word (smaller; viewers then space the line with their own font metrics).
pub fewer_tspans: bool,
}
impl Default for ExportOptions {
@ -149,6 +152,7 @@ impl Default for ExportOptions {
outline_text: false,
preserve_editing: false,
metadata: false,
fewer_tspans: false,
}
}
}

View File

@ -0,0 +1,188 @@
//! Laid-out type in SVG: every line of area type is its own positioned `<tspan>`, rich text keeps
//! its leading, baseline shift, scaling and kerning, and a renderer with the same font draws the
//! export where the canvas draws the text.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
fn style(size: f64) -> CharStyle {
CharStyle { size, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }
}
/// Area type `w` wide at (20, 20) in a 300 × 200 document.
fn area_doc(runs: Vec<TextRun>, w: f64, justify: Justify) -> Document {
let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0));
t.runs = runs;
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, 160.0)) };
t.para.justify = justify;
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn run(text: &str, st: CharStyle) -> TextRun {
TextRun { text: text.into(), style: st }
}
/// The `<tspan …>` start tags.
fn tspans(svg: &str) -> Vec<&str> {
svg.match_indices("<tspan").map(|(i, _)| &svg[i..i + svg[i..].find('>').unwrap()]).collect()
}
fn attr(tag: &str, name: &str) -> Option<f64> {
let k = format!(" {name}=\"");
let i = tag.find(&k)? + k.len();
tag[i..i + tag[i..].find('"')?].parse().ok()
}
#[test]
fn wrapped_lines_get_one_tspan_each() {
let d = area_doc(vec![run("alpha beta gamma delta epsilon zeta", style(12.0))], 70.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
let ys: Vec<f64> = tags.iter().map(|t| attr(t, "y").expect("each line is placed")).collect();
assert!(ys.len() >= 3, "the text wraps: {svg}");
assert!(ys.windows(2).all(|w| w[1] > w[0]), "baselines go down: {ys:?}");
// Auto leading: 120 % of the size between baselines.
assert!((ys[1] - ys[0] - 14.4).abs() < 0.01, "{ys:?}");
assert!(tags.iter().all(|t| attr(t, "x") == Some(0.0)), "left aligned at the frame's edge: {svg}");
// Reading it back gives a line per baseline, starting at the first baseline.
let back = vectorcraft_svg::import(&svg).unwrap();
let text = back.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.clone()) } else { None }).unwrap();
assert_eq!(text.plain_text().lines().count(), ys.len(), "{:?}", text.plain_text());
assert!((text.xf.translation().y - (20.0 + ys[0])).abs() < 0.01);
// Centred lines are anchored at the frame's centre, so they stay centred in any font.
let svg = export(&area_doc(vec![run("alpha beta gamma delta", style(12.0))], 70.0, Justify::Center), &ExportOptions::default());
assert!(svg.contains("text-anchor=\"middle\""), "{svg}");
assert!(tspans(&svg).iter().all(|t| (attr(t, "x").unwrap() - 35.0).abs() < 0.01), "{svg}");
// Left-aligned (and justified) lines are placed absolutely.
assert!(!export(&d, &ExportOptions::default()).contains("text-anchor"));
}
#[test]
fn rich_text_writes_spacing_shift_and_scale() {
let shifted = CharStyle { baseline_shift: 4.0, ..style(12.0) };
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
let kerned = CharStyle { tracking: 50.0, kerning: Some(-20.0), ..style(12.0) };
let tall = CharStyle { v_scale: 200.0, h_scale: 200.0, leading: Some(30.0), ..style(12.0) };
let d = area_doc(
vec![run("Base ", style(12.0)), run("up ", shifted), run("wide ", wide), run("kern\n", kerned), run("tall", tall)],
280.0,
Justify::Left,
);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 5, "{svg}");
assert_eq!(attr(tags[1], "dy"), Some(-4.0), "baseline shift: {}", tags[1]);
assert!(attr(tags[2], "textLength").is_some() && tags[2].contains("lengthAdjust=\"spacingAndGlyphs\""), "{}", tags[2]);
assert!(tags[3].contains("letter-spacing=\"0.36\"") && tags[3].contains("font-kerning:none"), "tracking + kerning: {}", tags[3]);
assert!(tags[4].contains("font-size=\"24\"") && attr(tags[4], "textLength").is_none(), "uniform scale is the size: {}", tags[4]);
assert!((attr(tags[4], "y").unwrap() - attr(tags[0], "y").unwrap() - 30.0).abs() < 0.01, "leading");
// Segments on one baseline share its y; the shifted one is placed with dy.
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
}
#[test]
fn fewer_tspans_writes_one_per_line() {
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
let runs = vec![run("one ", style(12.0)), run("two", bold), run(" three\nfour", style(12.0))];
let d = area_doc(runs, 280.0, Justify::Left);
let full = export(&d, &ExportOptions::default());
let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() });
let placed = |s: &str| tspans(s).iter().filter(|t| attr(t, "y").is_some()).count();
assert_eq!(placed(&full), 4, "{full}");
assert_eq!(placed(&fewer), 2, "one positioned tspan per line: {fewer}");
assert!(fewer.contains("font-weight=\"bold\""), "style changes stay: {fewer}");
assert!(fewer.len() < full.len());
}
#[test]
fn justified_lines_place_each_word() {
let d = area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", style(12.0))], 90.0, Justify::JustifyLeft);
let svg = export(&d, &ExportOptions::default());
let first_line_y = attr(tspans(&svg)[0], "y").unwrap();
let words = tspans(&svg).iter().filter(|t| attr(t, "y") == Some(first_line_y)).count();
assert!(words >= 3, "{svg}");
}
/// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG
/// user space.
fn viewer_outlines(svg: &str) -> Vec<BezPath> {
let mut db = usvg::fontdb::Database::new();
db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap());
let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() };
let tree = usvg::Tree::from_str(svg, &opt).unwrap();
fn walk(g: &usvg::Group, out: &mut Vec<BezPath>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => walk(g, out),
usvg::Node::Text(t) => walk(t.flattened(), out),
usvg::Node::Path(p) => {
let m = p.abs_transform();
let m = Affine::new([m.sx as f64, m.ky as f64, m.kx as f64, m.sy as f64, m.tx as f64, m.ty as f64]);
let mut bp = BezPath::new();
for s in p.data().segments() {
use usvg::tiny_skia_path::PathSegment as S;
let pt = |q: usvg::tiny_skia_path::Point| m * Point::new(q.x as f64, q.y as f64);
match s {
S::MoveTo(a) => bp.move_to(pt(a)),
S::LineTo(a) => bp.line_to(pt(a)),
S::QuadTo(a, b) => bp.quad_to(pt(a), pt(b)),
S::CubicTo(a, b, c) => bp.curve_to(pt(a), pt(b), pt(c)),
S::Close => bp.close_path(),
}
}
out.push(bp);
}
usvg::Node::Image(_) => {}
}
}
}
let mut out = vec![];
walk(tree.root(), &mut out);
out
}
#[test]
fn a_viewer_draws_the_text_where_the_canvas_does() {
// With a tab every line is placed absolutely; without one, centred and right-aligned lines are
// anchored, and a baseline shift mid-line is undone by the next run.
for (sep, shift) in [(" ", 0.0), (" ", 3.0)] {
let second = |fill: Color| CharStyle { fill: Paint::solid(fill), baseline_shift: shift, ..style(14.0) };
let runs = |second: CharStyle| {
vec![
run("The quick brown fox jumps over the lazy dog. ", style(14.0)),
run(&format!("Pack my box{sep}with"), second),
run(" five dozen jugs.", style(14.0)),
]
};
for justify in [Justify::Left, Justify::Center, Justify::Right] {
for fewer_tspans in [false, true] {
let o = ExportOptions { fewer_tspans, ..Default::default() };
let svg = export(&area_doc(runs(second(Color::rgb(0.8, 0.0, 0.0))), 200.0, justify), &o);
assert!(svg.contains("fill=\"#cc0000\""), "{svg}");
// The viewer's glyphs as black shapes, against the canvas drawing the text all black.
let mut viewer = Document::new(300.0, 200.0);
let l = viewer.layers[0].id;
let outlines = viewer_outlines(&svg);
assert!(outlines.iter().map(|b| b.elements().len()).sum::<usize>() > 200, "the viewer laid the text out");
for bp in outlines {
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let n = Node::path(viewer.alloc_id(), PathData::from_bezpath(&bp), ap);
viewer.insert(Some(l), usize::MAX, n).unwrap();
}
let canvas = area_doc(runs(second(Color::BLACK)), 200.0, justify);
assert_similar(&render_artboard(&canvas), &render_artboard(&viewer), 40.0, 0.01);
}
}
}
}

View File

@ -168,6 +168,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(12.0);
check(ui, d, "responsive", "Responsive", true);
});
check(ui, d, "fewerTspans", "Fewer <tspan> Elements", !d.bool("outlineText"));
check(ui, d, "metadata", "Include Metadata", true);
if mode == Mode::Export {
artboards(app, ui, d);

View File

@ -21,7 +21,7 @@
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,

View File

@ -126,7 +126,7 @@ SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}
`style`, `entities`, `css`), `outlineText`, `images` (`embed`, or `link`: embedded images are written next to the
SVG, or returned as `linked`), `objectIds` (`layerNames`, `minimal`, `unique`), `decimals` (1–7), `minify`,
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`), `preserveEditing` (the SVG
reopens as the full document) and `metadata`. Unknown keys inside `svg` are rejected; `run_command document.formats`
reopens as the full document), `metadata` and `fewerTspans` (one `<tspan>` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats`
lists every option with its default. `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG.
## Resources