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 <title>
(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.
This commit is contained in:
Taha-Mahmoodi
2026-10-22 18:56:34 +07:00
committed by bflatastic
parent cc499d7e8e
commit 439d32fcf8
7 changed files with 85 additions and 7 deletions
+4 -2
View File
@@ -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)
};
+3 -2
View File
@@ -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) {
@@ -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("</title>")).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<Option<String>> {
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())]);
}
+5 -2
View File
@@ -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!("</defs>{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!("<title>{}</title>{nl}", xml_text(&doc.title)));
out.push_str(&format!("<title>{}</title>{nl}", xml_text(title)));
}
// File Info's description.
if !doc.metadata.description.trim().is_empty() {
+8
View File
@@ -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<usize>,
/// 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 `<title>` 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,
+28
View File
@@ -24,3 +24,31 @@ fn title_and_description_markup_is_escaped() {
assert!(svg.contains("<title>A &amp; &lt;B&gt;</title>"));
assert!(svg.contains("<desc>x &amp; y</desc>"));
}
/// #3544: an artboard exported on its own has its own name as `<title>` (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 &lt;right&gt; &amp; co</title>"));
assert_eq!(title(&d, alone(Some(0))).as_deref(), Some("<title>arrow-left</title>"));
// The document: Save's first artboard, or the bounds of all art.
assert_eq!(title(&d, ExportOptions::default()).as_deref(), Some("<title>Icons</title>"));
assert_eq!(title(&d, ExportOptions { artboard: Some(1), ..ExportOptions::default() }).as_deref(), Some("<title>Icons</title>"));
assert_eq!(title(&d, alone(None)).as_deref(), Some("<title>Icons</title>"));
// 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("<title>Icons</title>"));
}
+1 -1
View File
@@ -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 `<title>`, 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) |