From 1132cf0c79524bacd953e70712898ea38ca2fa49 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:18:21 -0800 Subject: [PATCH] SVG: Save As SVG can include the document's variables and data sets SVG Options gets an Include Variable Data checkbox (the `variableData` option, off by default). With it on, the saved SVG carries the document's text variables, its data sets' text values and each text variable's bound type object as a `variableSets` element, in the layout Load Variable Library already reads. A bound object is named by its element id in a `docRef="id('...')"` attribute; an object without an id gets one. Opening such an SVG reads the variable data back: the text variables, the data sets (none active) and the bindings, so choosing a data set changes the type again. Variable data that Load Variable Library would refuse is left out with a warning, and the art opens. Markup that only looks like the element (in comments, CDATA sections, processing instructions or the document type declaration) is skipped. Variables of other kinds, names XML can't hold, more than 256 text variables or 1,000 data sets, and variable data over 8 MiB or 2,000,000 XML nodes are left out of the file, each with a warning. Type written as outlines opens as art, so its variables are written unbound, with a warning. --- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/load.rs | 31 ++- crates/engine/src/cmd/fileio/svg.rs | 6 + crates/engine/src/cmd/variablecmds.rs | 1 + crates/engine/src/cmd/variablecmds/library.rs | 178 +++++++++++++- crates/engine/src/tests/variables.rs | 188 +++++++++++++++ crates/svg/src/export.rs | 9 + crates/svg/src/export/output.rs | 5 +- crates/svg/src/export/text.rs | 1 + crates/svg/src/export/variables.rs | 223 ++++++++++++++++++ crates/svg/src/import.rs | 19 +- crates/svg/src/import/importer.rs | 8 +- crates/svg/src/lib.rs | 14 ++ crates/ui-egui/src/dialogs/svg_options.rs | 21 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 2 +- docs/mcp.md | 15 +- 43 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 crates/svg/src/export/variables.rs diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 7f95b8e203..fe7c9d8f3e 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ec1d7e36ba..052b1683d8 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); // Relative links are found in the SVG's folder (when `name` is a path). let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); - restore_or_import(editing, || import_svg(&text, folder.as_deref()))? + restore_or_import(editing, || open_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // The file is read and checked once, for the document it carries, its pages, the @@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG +/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable +/// data that can't be read is left out with a warning; the art opens either way. +fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + let lib = match crate::cmd::variablecmds::svg_library(text) { + None => return import_svg(text, folder), + Some(Err(e)) => { + let (doc, mut warnings) = import_svg(text, folder)?; + warnings.push(format!("the file's variable data wasn't read: {e}")); + return Ok((doc, warnings)); + } + Some(Ok(lib)) => lib, + }; + let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?; + warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found)); + Ok((doc, warnings)) +} + +/// [`import_svg`], also returning the object made of each element whose id `ids` lists. +fn import_svg_finding( + text: &str, + folder: Option<&str>, + ids: &std::collections::HashSet, +) -> Result<(Document, Vec, vectorcraft_svg::Found)> { // The importer reads on a thread of its own (for its stack): what may be read is what may be // read here. let scope = crate::file_access::ScopeHandle::capture(); @@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Some((bytes, link)) }) }; - vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) + vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err) } /// Open a file's bytes as the new active document (what `document.open` does) → diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..eebac553d3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)", }, + FormatOption { + name: "variableData", + ty: "boolean", + default: "false", + description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..241487112b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -27,6 +27,7 @@ mod csv; mod library; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; /// The longest variable or dataset name, in characters (names arrive from MCP and files). diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 54f3689e99..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use super::{ - BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, - read_file_head, str_param, variables_unlocked, write_file, + BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind, + Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file, }; /// The namespace of a variable library's elements, the default namespace of its data sets' values, @@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result { } /// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { +/// order it declares them, the name and `trait` of each variable of another kind, its data sets +/// with their text values, and the element id each text variable's `docRef` names. +pub(crate) struct Library { text: Vec, other: Vec<(String, String)>, datasets: Vec, + refs: Vec<(String, String)>, +} + +impl Library { + /// The element ids the text variables' `docRef`s name. + pub(crate) fn elements(&self) -> std::collections::HashSet { + self.refs.iter().map(|(_, e)| e.clone()).collect() + } } /// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each @@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, "the file holds no `variableSets` element")); }; let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { @@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } match var.attribute("trait") { - Some("textcontent") => text.push(name), + Some("textcontent") => { + // `docRef="id('…')"` names the element of the variable's bound object. + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + if let Some(element) = element { + refs.push((name.clone(), element.to_string())); + } + text.push(name); + } Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), } @@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } datasets.push(DataSet { name, values }); } - Ok(Library { text, other, datasets }) + Ok(Library { text, other, datasets, refs }) +} + +/// The variable library an SVG carries (SVG Options › Include Variable Data), read as +/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The +/// element is read on its own, from its start tag to the end tag that closes it +/// ([`variable_sets_range`]), with each reference to an +/// entity whose name starts with `ns_` replaced by the value the text before the element declares +/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as +/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and +/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An +/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused. +pub(crate) fn svg_library(svg: &str) -> Option> { + const C: &str = "document.open"; + let (start, end) = variable_sets_range(svg)?; + let read = || { + let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?; + let element = svg.get(start..end).unwrap_or_default(); + if element.len() > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut entities: Vec<(String, String)> = Vec::new(); + for decl in svg.get(..start).unwrap_or_default().split("(); + if element.len() + grown > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut element = element.to_string(); + for (name, value) in &entities { + element = element.replace(name.as_str(), value); + } + parse_library(&format!("{element}"), C) + }; + Some(read()) +} + +/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag +/// that closes it ends (`None` when no end tag does), or `None` when there is no such element. +/// Comments, CDATA sections, processing instructions and the document type declaration are +/// skipped, so text inside them that looks like the element is not taken for it. +fn variable_sets_range(svg: &str) -> Option<(usize, Option)> { + const NAME: &str = "variableSets"; + // The element's name ends at white space, `>` or `/`. + let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/'))); + if !svg.contains("") + } else if rest.starts_with("") + } else if rest.starts_with("") + } else if rest.starts_with("`. + match (rest.find('['), rest.find('>')) { + (Some(open), Some(gt)) if open < gt => { + rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1)) + } + (_, gt) => gt.map(|g| at + g + 1), + } + } else if let Some(close) = rest.strip_prefix(""); + if let Some(s) = start + && named(close) + { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some((s, tag_end)); + } + } + tag_end + } else { + let tag_end = past(">"); + if named(rest.get(1..).unwrap_or_default()) { + let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>")); + if start.is_none() { + start = Some(at); + } + if empty && depth == 0 { + return Some((at, tag_end)); + } + depth += usize::from(!empty); + } + tag_end + }; + let Some(next) = next else { break }; + at = next; + } + start.map(|s| (s, None)) +} + +/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the +/// library's text variables, its data sets, no active data set, and each text variable bound to +/// the type object made of the element its `docRef` names, or the one type object among the +/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef` +/// that names no type object, or one another variable is bound to already, binds nothing. The art +/// stays as the file draws it. The warning about the variables of other kinds that were left out, +/// if any. +pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap) -> Option { + let mut vars = + Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() }; + vars.datasets = lib.datasets; + // A type object written with a paint of its own, or with fills and strokes of its appearance, + // is a group around its text (and the appearance's paths). + let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => Some(id), + Some(NodeKind::Group { children, .. }) => { + let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_))); + match (texts.next(), texts.next()) { + (Some(one), None) => Some(one.id), + _ => None, + } + } + _ => None, + }; + for (name, element) in &lib.refs { + let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue }; + if !vars.bindings.contains_key(&id) { + vars.bind(id, name); + } + } + doc.variables = vars.into(); + left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) } /// Load Variable Library: replace the document's variables and data sets with a variable library's diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 4b773c889c..08ba426ef1 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() { s.execute("dataset.select", &json!({"name": "B"})).unwrap(); assert_eq!(super::history(&s), (before.0 + 1, 0, true)); } + +/// The document's SVG with the options `svg` → (its text, the export's warnings). +fn svg_export(s: &mut Session, svg: Value) -> (String, Vec) { + let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default()) +} + +/// A new session with `svg` opened as `name` → (the session, the open's warnings). +fn svg_opened(svg: &str, name: &str) -> (Session, Vec) { + let mut o = Session::new(); + let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + (o, warnings) +} + +/// Include Variable Data writes the text variables, the data sets' text values and each text +/// variable's bound type object into the SVG, and opening the SVG reads them back: the type +/// objects are bound again, so a data set changes them. Variables of other kinds are left out with +/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style +/// entities alike, and Load Variable Library reads the same file. +#[test] +fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let u = text(&mut s, "9.99"); + // A fill of its appearance writes the type in a group with the fill's path. + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + let r = rect(&mut s); + for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A & ", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let text_only: Vec = before + .datasets + .iter() + .map(|d| { + let mut d = d.clone(); + d.values.retain(|k, _| k != "Shown"); + d + }) + .collect(); + + for opts in [ + json!({"variableData": true}), + json!({"variableData": true, "objectIds": "minimal", "minify": true}), + json!({"variableData": true, "objectIds": "unique"}), + json!({"variableData": true, "styling": "entities", "encoding": "latin1"}), + ] { + let (svg, warnings) = svg_export(&mut s, opts.clone()); + assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}"); + assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}"); + assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing"); + + let (mut o, warnings) = svg_opened(&svg, "vars.svg"); + assert!(warnings.is_empty(), "{opts}: {warnings:?}"); + let vars = o.doc().unwrap().doc.variables.clone(); + assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::>(), [("Name", "text"), ("Price", "text")]); + assert_eq!(vars.datasets, text_only, "{opts}"); + assert_eq!(vars.active_dataset, None); + let bound = |name: &str| { + let ids = vars.objects_of(name); + assert_eq!(ids.len(), 1, "{opts}: {name}"); + ids[0] + }; + let (name, price) = (bound("Name"), bound("Price")); + assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it"); + let v = o.execute("dataset.select", &json!({"name": "A & "})).unwrap(); + assert_eq!(v["applied"], json!(2), "{opts}: {v}"); + assert_eq!(plain(&o, name), "Bob"); + + let mut lib = session(); + lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap(); + assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}"); + } +} + +/// Without Include Variable Data the SVG holds no variable data, and a document without variables +/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left +/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each +/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a +/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file +/// whose variable data can't be read opens its art with a warning, and so does one whose +/// namespace entities would grow the library past its cap. +#[test] +fn an_svg_without_readable_variable_data_opens_its_art() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let (plain_svg, _) = svg_export(&mut s, json!({})); + assert!(!plain_svg.contains("variableSets")); + let (none, _) = svg_export(&mut s, json!({"variableData": true})); + assert!(!none.contains("variableSets"), "no variables, no variable data"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let (off, _) = svg_export(&mut s, json!({})); + assert!(!off.contains("variableSets"), "off by default"); + // Outlined type opens as art: the variable is written unbound, and the values lose the + // characters XML can't hold, each with a warning. + s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap(); + let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true})); + assert!(outlined.contains(""; + let art = ""; + let untraited = format!("{head}{art}"); + let refs = "&ns_big;".repeat(40_000); + let big = "x".repeat(250); + let growing = format!( + "]>{head}{art}" + ); + // Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million + // one-character paragraphs, minified) is left out with a warning. + let mut lines = session(); + lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap(); + let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true})); + assert!(!dense.contains("variableSets")); + assert!( + warnings.contains(&json!( + "The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG" + )), + "{warnings:?}" + ); + + // More text variables and data sets than Load Variable Library reads: the first 256 and the + // first 1,000 are written, each with a warning, and the file opens with them. + let mut many = session(); + { + let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables; + vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect(); + vars.datasets = (0..1_100) + .map(|i| vectorcraft_doc::DataSet { + name: format!("D{i}"), + values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(), + }) + .collect(); + } + let (capped, warnings) = svg_export(&mut many, json!({"variableData": true})); + for w in [ + "Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data", + "Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, warnings) = svg_opened(&capped, "many.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + let vars = &o.doc().unwrap().doc.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000)); + assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into())); + + // Text that only looks like variable data, in a comment, a CDATA section, a processing + // instruction or the document type declaration, is skipped for the element itself. + let library = ""; + let decoys = format!( + " -->]>{head} ]]> ?>{library}{art}" + ); + let (o, warnings) = svg_opened(&decoys, "decoys.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1); + + for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] { + let (o, warnings) = svg_opened(&svg, "broken.svg"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}"); + let d = &o.doc().unwrap().doc; + assert!(d.variables.variables.is_empty()); + assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..5fed1fb121 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -26,6 +26,7 @@ mod nodes; mod output; mod paint; mod text; +mod variables; use nodes::SymbolDef; pub(crate) use nodes::{MASK_FLAGS, Reuse}; @@ -117,8 +118,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: layer_nest: 0, non_latin1_names: false, foreign_ns: BTreeMap::new(), + bound: HashMap::new(), }; w.assign_name_ids(); + w.assign_bound_ids(); for layer in &doc.layers { w.count_colorized_images(layer); } @@ -135,6 +138,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: w.line(""); } + // Include Variable Data, once the body has written the bound objects' ids. + let variables = w.variable_sets(); // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); @@ -207,6 +212,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("