diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 33f180c9..e48266a5 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index d4703a80..7db8108c 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -55,6 +55,12 @@ pub const OPTIONS: &[FormatOption] = &[ description: "embed the native document in so VectorCraft reopens the SVG with nothing lost", }, FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write with the title and format (Dublin Core)" }, + FormatOption { + name: "fewerTspans", + ty: "boolean", + default: "false", + description: "type: one positioned per line instead of one per style run, tab and justified word (smaller; viewers space the line themselves)", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 52497476..89a10d19 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -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##"plain red plain"##; + s.execute("document.open", &json!({"name": "two-runs.svg", "dataBase64": vectorcraft_format::base64_encode(src.as_bytes())})).unwrap(); + let placed = |svg: &str| svg.matches("; -/// 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 = st @@ -1242,21 +1241,106 @@ impl Writer<'_> { self.line(""); } + /// 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!("")); - (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> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect(); + // The `` 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!(""); + 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(""); + self.line(&s); + } + + /// One line of laid-out text starting at `start`. Default: a positioned `` per segment + /// (only the first of an `anchored` line). Fewer tspans: one positioned `` 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!("", 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!("{text}")); + } + } + if fewer { + s.push_str(""); + } + } + + /// Type on a path: a `` 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!("")); + 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 `` 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!(""); - if let Some((pid, start)) = &on_path { - s.push_str(&format!("", fmt_num(start * 100.0, 3))); + let offset = fmt_num(start * 100.0, 3); + let mut s = format!(""); + for run in &t.runs { + let diff = run_diff(&base, self.char_props(&run.style)); + let a = self.attrs(&diff); + s.push_str(&format!("{}", 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!("{}{hy}", 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!("{}", xml_escape(piece))); - } - } - } - if on_path.is_some() { - s.push_str(""); - } - s.push_str(""); + s.push_str(""); 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> { - 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 `` of a run whose properties are `run` sets over its ``'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 ``. +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> { + 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 = 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) -> 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)) +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index da0d55cf..6e4ebaa1 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -134,6 +134,9 @@ pub struct ExportOptions { pub preserve_editing: bool, /// Write `` with the document's Dublin Core title and format. pub metadata: bool, + /// One positioned `` 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, } } } diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs new file mode 100644 index 00000000..8f536303 --- /dev/null +++ b/crates/svg/tests/text_layout.rs @@ -0,0 +1,188 @@ +//! Laid-out type in SVG: every line of area type is its own positioned ``, 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, 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 `` start tags. +fn tspans(svg: &str) -> Vec<&str> { + svg.match_indices("').unwrap()]).collect() +} + +fn attr(tag: &str, name: &str) -> Option { + 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 = 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 { + 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) { + 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::() > 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); + } + } + } +} diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 94aa1965..91ee5208 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -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 Elements", !d.bool("outlineText")); check(ui, d, "metadata", "Include Metadata", true); if mode == Mode::Export { artboards(app, ui, d); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 530f0a43..c4ebd9db 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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, diff --git a/docs/mcp.md b/docs/mcp.md index 63987d4d..6bde7aea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 `` 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