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`; 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("")?.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) |