From 439d32fcf8a477d727158056dd3e4e89efcd4f79 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:48:52 +0430 Subject: [PATCH] Fix #3544: an SVG of one artboard on its own has the artboard's name as its title The SVG writer always wrote the document's title in (crates/svg/src/export.rs), so every file of Export As with Use Artboards, Export for Screens or an `artboard`/`range` export was titled after the document ("Icons"), whatever its artboard was called. The writer's `artboard` option can't tell those apart from Save: Save writes the first artboard too, standing for the whole document. ExportOptions has a new `artboard_title` (set by the caller, as `artboard` is, not deserialized): the file is that one artboard, exported on its own, and its <title> is the artboard's name (XML-escaped as before), or the document's title when the artboard has no name. The engine's SVG encoder (fileio/svg.rs) sets it exactly when the export names its artboards, which is also when a file holds only that artboard's art (#550): Export As per artboard, document.export / serialize with `artboard` or `range`, MCP export, and Export for Screens' artboards, assets, layers and selections (each written as an artboard named after it). Save and Save As keep the document's title. Export for Screens' Full Document draws the art bounds as one artboard, which is now named after the document, so its SVG keeps the document's title too. docs/file-format-parity.md's SVG row says so. Tests: title_control_chars::an_artboard_exported_alone_is_titled_after_it (crates/svg: two named artboards, the second named with markup; exporting either alone gives its escaped name, the default and an artboard without artboard_title give the document's title, an unnamed artboard falls back to it) and tests_svgenc::an_svg_of_one_artboard_is_titled_after_it (the engine: `artboard` 0 and 1, useArtboards range all, Save's SVG, Export for Screens per artboard, an asset and Full Document). Both fail without the change (the title was "Icons") and pass with it. --- crates/engine/src/cmd/fileio/screens.rs | 6 ++-- crates/engine/src/cmd/fileio/svg.rs | 5 +-- crates/engine/src/cmd/fileio/tests_svgenc.rs | 36 ++++++++++++++++++++ crates/svg/src/export.rs | 7 ++-- crates/svg/src/lib.rs | 8 +++++ crates/svg/tests/title_control_chars.rs | 28 +++++++++++++++ docs/file-format-parity.md | 2 +- 7 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 0da52a9a4..4c3816ebd 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -800,10 +800,12 @@ fn items( }; if o.full_document { // The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF - // row writes every artboard of the document itself). + // row writes every artboard of the document itself). It is named after the document: an + // SVG of one artboard takes the artboard's name as its title (#3544), and this one stands + // for the whole document. let (whole, region) = if crop { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - (Arc::new(single_artboard(doc, bounds, "Document")), bounds) + (Arc::new(single_artboard(doc, bounds, &doc.title)), bounds) } else { (doc.clone(), Rect::ZERO) }; diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 8f028934a..0651f0039 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -179,9 +179,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco let doc = flat.as_ref().unwrap_or(doc); let mut enc = Encoded { warnings, ..Encoded::default() }; for artboard in boards { - // A chosen artboard's file holds the art over it (#550). + // A chosen artboard's file holds the art over it (#550) and is named after it (#3544). let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect)); - let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref()); + let file = ExportOptions { artboard, artboard_title: chosen, ..opts.clone() }; + let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &file, native.as_deref()); let bytes = out.take_bytes(); for l in out.linked { if !enc.linked.iter().any(|e| e.name == l.name) { diff --git a/crates/engine/src/cmd/fileio/tests_svgenc.rs b/crates/engine/src/cmd/fileio/tests_svgenc.rs index 3f96c4d75..f901115d1 100644 --- a/crates/engine/src/cmd/fileio/tests_svgenc.rs +++ b/crates/engine/src/cmd/fileio/tests_svgenc.rs @@ -108,3 +108,39 @@ fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() { s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap(); assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard"); } + +/// #3544: an SVG of one artboard on its own (an `artboard` or `range`, Export for Screens, an asset) +/// has the artboard's (or asset's) name as `<title>`; Save's SVG and Export for Screens' Full +/// Document keep the document's. +#[test] +fn an_svg_of_one_artboard_is_titled_after_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Icons", "width": 24, "height": 24, "artboards": 2})).unwrap(); + for (index, name) in [(0, "arrow-left"), (1, "arrow-right")] { + s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap(); + } + let square = s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap()["id"].clone(); + let title = |svg: &str| svg.lines().find_map(|l| l.trim().strip_prefix("<title>")?.strip_suffix("")).map(str::to_string); + let serialize = |s: &mut Session, p: serde_json::Value| s.execute("document.serialize", &p).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 1}))).as_deref(), Some("arrow-right")); + assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 0}))).as_deref(), Some("arrow-left")); + assert_eq!(title(&serialize(&mut s, json!({"format": "svg"}))).as_deref(), Some("Icons"), "Save's SVG"); + let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap(); + let titles: Vec<_> = all["files"].as_array().unwrap().iter().map(|f| title(f["text"].as_str().unwrap())).collect(); + assert_eq!(titles, [Some("arrow-left".into()), Some("arrow-right".into())]); + // Export for Screens: each artboard, an asset, and the Full Document. + let screens = |s: &mut Session, p: serde_json::Value| -> Vec> { + let r = s.execute("document.exportForScreens", &p).unwrap(); + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| title(&String::from_utf8(vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()).unwrap())) + .collect() + }; + let svg = json!([{"format": "svg"}]); + assert_eq!(screens(&mut s, json!({"formats": svg})), [Some("arrow-left".into()), Some("arrow-right".into())]); + let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Square"})).unwrap()["assets"][0].clone(); + assert_eq!(screens(&mut s, json!({"assets": [asset], "formats": svg})), [Some("Square".into())]); + assert_eq!(screens(&mut s, json!({"fullDocument": true, "formats": svg})), [Some("Icons".into())]); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 3bfa57b9e..45e9438d0 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -133,9 +133,12 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("{nl}")); } - if !doc.title.is_empty() { + // An artboard exported on its own is named after it (#3544), else after the document. + let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| a.name.as_str()); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or(&doc.title); + if !title.is_empty() { out.push_str(&w.indent(1)); - out.push_str(&format!("{}{nl}", xml_text(&doc.title))); + out.push_str(&format!("{}{nl}", xml_text(title))); } // File Info's description. if !doc.metadata.description.trim().is_empty() { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index bfbce083a..da6883187 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -190,6 +190,13 @@ pub struct ExportOptions { /// it (the engine's `artboard`/`range`/`useArtboards` params), so it is not deserialized. #[serde(skip)] pub artboard: Option, + /// The file is the one artboard `artboard` names, exported on its own (Export As with Use + /// Artboards, Export for Screens, Asset Export, an `artboard`/`range` param): its `` is + /// the artboard's name, else (unnamed) the document's title (#3544). Off, the file stands for + /// the document (Save, Save As) and has the document's title. Callers set it with `artboard`, + /// so it is not deserialized either. + #[serde(skip)] + pub artboard_title: bool, pub styling: Styling, /// Decimal places for coordinates (1–7 in the dialog; default 3). pub decimals: u8, @@ -225,6 +232,7 @@ impl Default for ExportOptions { fn default() -> Self { Self { artboard: Some(0), + artboard_title: false, styling: Styling::PresentationAttributes, decimals: 3, object_ids: ObjectIds::LayerNames, diff --git a/crates/svg/tests/title_control_chars.rs b/crates/svg/tests/title_control_chars.rs index c7f842c0b..90c3d8c34 100644 --- a/crates/svg/tests/title_control_chars.rs +++ b/crates/svg/tests/title_control_chars.rs @@ -24,3 +24,31 @@ fn title_and_description_markup_is_escaped() { assert!(svg.contains("<title>A & <B>")); assert!(svg.contains("x & y")); } + +/// #3544: an artboard exported on its own has its own name as `` (escaped); the document +/// (Save, or no artboard of its own) keeps the document's title, and so does an unnamed artboard. +#[test] +fn an_artboard_exported_alone_is_titled_after_it() { + let mut d = Document::new(24.0, 24.0); + d.title = "Icons".into(); + let mut second = d.artboards[0].clone(); + (second.id, second.name) = (2, "arrow <right> & co".into()); + second.rect = second.rect.with_origin((100.0, 0.0)); + d.artboards[0].name = "arrow-left".into(); + d.artboards.push(second); + let title = |d: &Document, opts: ExportOptions| { + let svg = export(d, &opts); + assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}"); + svg.lines().find(|l| l.contains("<title>")).map(|l| l.trim().to_string()) + }; + let alone = |artboard| ExportOptions { artboard, artboard_title: true, ..ExportOptions::default() }; + assert_eq!(title(&d, alone(Some(1))).as_deref(), Some("<title>arrow <right> & co")); + assert_eq!(title(&d, alone(Some(0))).as_deref(), Some("arrow-left")); + // The document: Save's first artboard, or the bounds of all art. + assert_eq!(title(&d, ExportOptions::default()).as_deref(), Some("Icons")); + assert_eq!(title(&d, ExportOptions { artboard: Some(1), ..ExportOptions::default() }).as_deref(), Some("Icons")); + assert_eq!(title(&d, alone(None)).as_deref(), Some("Icons")); + // An unnamed artboard falls back to the document's title. + d.artboards[1].name = " ".into(); + assert_eq!(title(&d, alone(Some(1))).as_deref(), Some("Icons")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4ca55b7c2..08c01b7d0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -96,7 +96,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |