mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
SVG: keep CSS classes of imported elements on export (#2414)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f7de3d6a01
commit
e0b40678ba
@@ -1719,6 +1719,10 @@ pub struct ObjectAttributes {
|
||||
/// the element's event attributes; nothing in VectorCraft runs them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub events: Vec<(String, String)>,
|
||||
/// The CSS classes the object had in an imported SVG, written back to `class` on export
|
||||
/// (beside the generated `cls-N` classes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub classes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
|
||||
|
||||
@@ -504,7 +504,7 @@ impl Writer<'_> {
|
||||
fn line(&mut self, s: &str) {
|
||||
let ind = self.indent(self.depth);
|
||||
self.body.push_str(&ind);
|
||||
self.body.push_str(s);
|
||||
self.body.push_str(&merge_class_attrs(s));
|
||||
if !self.opts.minify {
|
||||
self.body.push('\n');
|
||||
}
|
||||
@@ -630,6 +630,18 @@ impl Writer<'_> {
|
||||
if !out.is_empty() && n.is_layer() && self.layer_nest > 1 {
|
||||
out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER));
|
||||
}
|
||||
let classes: Vec<&str> = n
|
||||
.extras()
|
||||
.attrs
|
||||
.as_deref()
|
||||
.map_or(&[][..], |a| a.classes.as_slice())
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|c| !c.is_empty() && c.chars().all(xml_name_char))
|
||||
.collect();
|
||||
if !classes.is_empty() && !self.tiny() {
|
||||
out.push_str(&format!(" class=\"{}\"", xml_attr_escape(&classes.join(" "))));
|
||||
}
|
||||
let mut keys = HashSet::new();
|
||||
for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) {
|
||||
// Ours (`name`, `vc-…`) are written by the export itself.
|
||||
@@ -2425,3 +2437,25 @@ fn paints_opaque(n: &Node) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that has both its own classes and a generated `cls-N` would write `class` twice:
|
||||
/// join the two into one attribute (the first, the object's own, comes first).
|
||||
fn merge_class_attrs(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
const KEY: &str = " class=\"";
|
||||
let tag_end = s.find('>').unwrap_or(s.len());
|
||||
let tag = s.get(..tag_end).unwrap_or(s);
|
||||
let Some(first) = tag.find(KEY) else { return s.into() };
|
||||
let first_val = first + KEY.len();
|
||||
let Some(first_len) = tag.get(first_val..).and_then(|t| t.find('"')) else { return s.into() };
|
||||
let first_end = first_val + first_len;
|
||||
let Some(second) = tag.get(first_end..).and_then(|t| t.find(KEY)).map(|i| i + first_end) else { return s.into() };
|
||||
let second_val = second + KEY.len();
|
||||
let Some(second_len) = tag.get(second_val..).and_then(|t| t.find('"')) else { return s.into() };
|
||||
let second_end = second_val + second_len;
|
||||
let (Some(b), Some(head), Some(mid), Some(rest)) =
|
||||
(s.get(second_val..second_end), s.get(..first_end), s.get(first_end + 1..second), s.get(second_end + 1..))
|
||||
else {
|
||||
return s.into();
|
||||
};
|
||||
format!("{head} {b}\"{mid}{rest}").into()
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ struct Importer {
|
||||
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
|
||||
non_scaling: HashSet<String>,
|
||||
/// The `data-*` attributes of elements, by id ([`user_data`]).
|
||||
data: HashMap<String, Vec<(String, String)>>,
|
||||
data: HashMap<String, Extras>,
|
||||
/// The event attributes of elements, by id ([`user_events`]).
|
||||
events: HashMap<String, Vec<(String, String)>>,
|
||||
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
|
||||
@@ -492,7 +492,7 @@ struct Found {
|
||||
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
|
||||
non_scaling: HashSet<String>,
|
||||
/// The `data-*` attributes of elements, by id ([`user_data`]).
|
||||
data: HashMap<String, Vec<(String, String)>>,
|
||||
data: HashMap<String, Extras>,
|
||||
/// The event attributes of elements, by id ([`user_events`]).
|
||||
events: HashMap<String, Vec<(String, String)>>,
|
||||
/// The JavaScript files `<script>` elements link to ([`linked_scripts`]).
|
||||
@@ -597,7 +597,7 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) ->
|
||||
if !css_transform
|
||||
&& !not
|
||||
&& !events
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode", "script"]
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "class", "groupmode", "script"]
|
||||
.iter()
|
||||
.any(|t| svg.contains(t))
|
||||
{
|
||||
@@ -610,11 +610,12 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) ->
|
||||
found.links.insert(edits.id(svg, a), url.to_string());
|
||||
}
|
||||
}
|
||||
if svg.contains("data-") {
|
||||
if svg.contains("data-") || svg.contains("class") {
|
||||
for e in elements().filter(|e| OBJECT_ELEMENTS.contains(&e.tag_name().name())) {
|
||||
let data = user_data(e);
|
||||
if !data.is_empty() {
|
||||
found.data.insert(edits.id(svg, e), data);
|
||||
let classes = user_classes(e);
|
||||
if !data.is_empty() || !classes.is_empty() {
|
||||
found.data.insert(edits.id(svg, e), (data, classes));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -708,6 +709,25 @@ fn tag_name_end(svg: &str, n: XNode) -> usize {
|
||||
svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i)
|
||||
}
|
||||
|
||||
/// What import keeps of an element's own attributes: its `data-*` attributes and CSS classes.
|
||||
type Extras = (Vec<(String, String)>, Vec<String>);
|
||||
|
||||
/// The most CSS classes kept per element (the file is untrusted).
|
||||
const MAX_CLASSES: usize = 64;
|
||||
|
||||
/// The CSS classes of element `e`, in order and without repeats. Classes our own export
|
||||
/// generated (`cls-1`, with or without a prefix) aren't kept: export makes them again.
|
||||
fn user_classes(e: XNode) -> Vec<String> {
|
||||
let generated = |c: &str| c.rsplit_once("cls-").is_some_and(|(_, n)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for c in e.attribute("class").into_iter().flat_map(str::split_whitespace) {
|
||||
if out.len() < MAX_CLASSES && !generated(c) && !out.iter().any(|k| k == c) {
|
||||
out.push(c.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The elements that become objects, whose `data-*` attributes are kept.
|
||||
const OBJECT_ELEMENTS: &[&str] = &["g", "a", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image"];
|
||||
|
||||
@@ -913,12 +933,16 @@ impl Importer {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an
|
||||
/// Give `n` the `data-*` attributes and classes of element `id`, unless it has data already (what an
|
||||
/// inner element made of it keeps).
|
||||
fn keep_data(&self, id: &str, n: &mut Node) {
|
||||
if let Some(data) = self.data.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.data.is_empty())) {
|
||||
let Some((data, classes)) = self.data.get(id) else { return };
|
||||
if n.extras().attrs.as_ref().is_none_or(|a| a.data.is_empty()) {
|
||||
n.edit_attrs(|a| a.data = data.clone());
|
||||
}
|
||||
if n.extras().attrs.as_ref().is_none_or(|a| a.classes.is_empty()) {
|
||||
n.edit_attrs(|a| a.classes = classes.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Give `n` the event attributes of element `id`, unless it has events already (what an inner
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! CSS classes on imported elements come back on export, beside the generated `cls-N` classes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_svg::{ExportOptions, Styling, export, import};
|
||||
|
||||
const SRC: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><g id="grp" class="wrap big"><rect id="r" class="hero hero cls-7" width="20" height="20" fill="red"/><circle id="c" r="5" fill="blue"/></g></svg>"#;
|
||||
|
||||
fn class_of(svg: &str, id: &str) -> Option<String> {
|
||||
let doc = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
|
||||
let n = doc.descendants().find(|n| n.attribute("id") == Some(id))?;
|
||||
n.attribute("class").map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_classes_survive_every_styling_with_generated_ones() {
|
||||
let doc = import(SRC).unwrap();
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss, Styling::StyleEntities] {
|
||||
let out = export(&doc, &ExportOptions { styling, ..ExportOptions::default() });
|
||||
assert_eq!(class_of(&out, "grp").as_deref(), Some("wrap big"), "{styling:?}");
|
||||
let rect = class_of(&out, "r").unwrap();
|
||||
if styling == Styling::InternalCss {
|
||||
assert!(rect.starts_with("hero ") && rect.contains("cls-") && !rect.contains("cls-7"), "{rect}");
|
||||
} else {
|
||||
assert_eq!(rect, "hero");
|
||||
}
|
||||
assert!(class_of(&out, "c").is_none_or(|c| c.starts_with("cls-")));
|
||||
// A second round trip changes nothing.
|
||||
let again = export(&import(&out).unwrap(), &ExportOptions { styling, ..ExportOptions::default() });
|
||||
assert_eq!(class_of(&again, "r"), Some(rect), "{styling:?}");
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| 2026-10-14 | minor | SVG import: `:not()` selectors (#1799, #1557), stacked descendant selectors over deep nesting no longer stall (#1834, #1703), linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); nesting capped at 32 objects with self-using symbols and patterns opened without the offending instance (#1852); deep and self-referencing PDF and `.ai` data (#1871), decompression bombs (#1873), self-drawing brushes (#1872), hostile EPS, Affinity and native data (#1863) and huge ids (#1862) handled; a failed save is reported in a dialog and the old file is kept (#1865, #1685, #1693, #1694); type inside symbols gets its layout bounds back on opening a native file (#1833, #1738). Scores unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); scores unchanged |
|
||||
| 2026-10-16 | minor | SVG: CSS classes on imported elements (`class="hero"`) are kept on the object and written back on export, beside the generated `cls-N` classes (#2414); scores unchanged (estimated) |
|
||||
| 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) |
|
||||
| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared guarded writer; drawing, colour policy and editable import stay app-owned. Metadata regression coverage and immutable dependency checks added; scores and hours unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user