Merge pull request #1690 from dotCooCoo/feat/svg-style-rule-index
SVG import: the style cascade looks up each element's rules by id, class and type
This commit is contained in:
commit
9258c4ea57
|
|
@ -8,7 +8,7 @@
|
|||
//! order; a presentation attribute applies only when no rule sets the property.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use usvg::roxmltree;
|
||||
|
|
@ -188,6 +188,51 @@ struct Rule {
|
|||
decls: Rc<Vec<(String, String, bool)>>,
|
||||
}
|
||||
|
||||
/// The rules filed under the id of their selector's rightmost compound, else under its first
|
||||
/// class, else under its type. Rules whose rightmost compound has none of these (`*`, `[x]`) are
|
||||
/// in `any`. Each list holds positions in the rule list, in source order.
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
ids: HashMap<String, Vec<usize>>,
|
||||
classes: HashMap<String, Vec<usize>>,
|
||||
tags: HashMap<String, Vec<usize>>,
|
||||
any: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
fn new(rules: &[Rule]) -> Self {
|
||||
let mut index = Self::default();
|
||||
for (i, r) in rules.iter().enumerate() {
|
||||
let list = match r.selector.parts.first() {
|
||||
Some((c, _)) => match (&c.id, c.classes.first(), &c.tag) {
|
||||
(Some(id), _, _) => index.ids.entry(id.clone()).or_default(),
|
||||
(None, Some(class), _) => index.classes.entry(class.clone()).or_default(),
|
||||
(None, None, Some(tag)) => index.tags.entry(tag.clone()).or_default(),
|
||||
(None, None, None) => &mut index.any,
|
||||
},
|
||||
None => &mut index.any,
|
||||
};
|
||||
list.push(i);
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The positions of the rules to test against element `n`, each once, in source order: those
|
||||
/// filed under its id, its classes and its type, and those in `any`.
|
||||
fn candidates(&self, n: XNode) -> Vec<usize> {
|
||||
let mut out = self.any.clone();
|
||||
let mut add = |list: Option<&Vec<usize>>| out.extend(list.into_iter().flatten());
|
||||
add(n.attribute("id").and_then(|id| self.ids.get(id)));
|
||||
let mut seen = HashSet::new();
|
||||
for class in n.attribute("class").into_iter().flat_map(str::split_whitespace).filter(|c| seen.insert(*c)) {
|
||||
add(self.classes.get(class));
|
||||
}
|
||||
add(self.tags.get(n.tag_name().name()));
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Rules from every `<style>` element, in source order.
|
||||
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
// Drop comments.
|
||||
|
|
@ -245,6 +290,7 @@ fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
|||
/// The cascade over one parsed document.
|
||||
pub(super) struct Styles {
|
||||
rules: Vec<Rule>,
|
||||
index: Index,
|
||||
/// Declared properties of each element queried so far.
|
||||
cache: RefCell<HashMap<roxmltree::NodeId, Rc<HashMap<String, String>>>>,
|
||||
}
|
||||
|
|
@ -259,7 +305,8 @@ impl Styles {
|
|||
let css: String = st.children().filter_map(|c| c.text()).collect();
|
||||
parse_sheet(&css, &mut rules);
|
||||
}
|
||||
Self { rules, cache: RefCell::default() }
|
||||
let index = Index::new(&rules);
|
||||
Self { rules, index, cache: RefCell::default() }
|
||||
}
|
||||
|
||||
/// Properties set on `n` by rules and its `style` attribute, after the cascade.
|
||||
|
|
@ -276,7 +323,7 @@ impl Styles {
|
|||
}
|
||||
};
|
||||
let mut order = 0;
|
||||
for r in self.rules.iter().filter(|r| r.selector.matches(n)) {
|
||||
for r in self.index.candidates(n).into_iter().filter_map(|i| self.rules.get(i)).filter(|r| r.selector.matches(n)) {
|
||||
for (k, v, imp) in r.decls.iter() {
|
||||
order += 1;
|
||||
put(k, v, (*imp, false, r.selector.specificity, order));
|
||||
|
|
@ -306,6 +353,8 @@ impl Styles {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn with_doc(svg: &str, f: impl FnOnce(&roxmltree::Document, &Styles)) {
|
||||
|
|
@ -363,6 +412,170 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// What [`Styles::declared`] gave before the rules were indexed: every rule tested against `n`.
|
||||
fn declared_by_every_rule(st: &Styles, n: XNode) -> HashMap<String, String> {
|
||||
type Key = (bool, bool, (u32, u32, u32), usize);
|
||||
let mut best: HashMap<String, (Key, String)> = HashMap::new();
|
||||
let mut put = |name: &str, value: &str, key: Key| {
|
||||
if best.get(name).is_none_or(|(k, _)| key >= *k) {
|
||||
best.insert(name.to_string(), (key, value.to_string()));
|
||||
}
|
||||
};
|
||||
let mut order = 0;
|
||||
for r in st.rules.iter().filter(|r| r.selector.matches(n)) {
|
||||
for (k, v, imp) in r.decls.iter() {
|
||||
order += 1;
|
||||
put(k, v, (*imp, false, r.selector.specificity, order));
|
||||
}
|
||||
}
|
||||
if let Some(s) = n.attribute("style") {
|
||||
for (k, v, imp) in parse_decls(s) {
|
||||
order += 1;
|
||||
put(&k, &v, (imp, true, (0, 0, 0), order));
|
||||
}
|
||||
}
|
||||
best.into_iter().map(|(k, (_, v))| (k, v)).collect()
|
||||
}
|
||||
|
||||
/// Checks that each element of `svg` gets the properties [`declared_by_every_rule`] gives it.
|
||||
fn assert_same_as_every_rule(svg: &str) {
|
||||
let doc = roxmltree::Document::parse(svg).unwrap();
|
||||
let st = Styles::new(&doc);
|
||||
for n in doc.descendants().filter(|n| n.is_element()) {
|
||||
assert_eq!(*st.declared(n), declared_by_every_rule(&st, n), "{n:?} in {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Id, class, type, universal and attribute selectors, descendant and child combinators, and
|
||||
/// rules that override each other give each element what testing every rule gives it.
|
||||
#[test]
|
||||
fn indexed_rules_give_what_testing_every_rule_gives() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
|
||||
* { stroke: s0 }
|
||||
g rect { fill: f1 }
|
||||
#a { fill: f2 }
|
||||
.a { fill: f3 }
|
||||
rect.a { fill: f4 }
|
||||
.b.a { stroke: s5 }
|
||||
g > .c { fill: f6 }
|
||||
svg > g g rect { font-size: 9px }
|
||||
[data-k=v] { display: none }
|
||||
#a > tspan, text .x { font-size: 8px }
|
||||
.c { fill: f8 !important }
|
||||
rect { fill: f9 }
|
||||
.a { fill: f10 }
|
||||
[data-k] { fill: f11 }
|
||||
</style>
|
||||
<g class="c">
|
||||
<rect id="a" class="a"/>
|
||||
<rect id="r2" class="b	a a"/>
|
||||
<g class="c x"><rect id="r3" class="c" data-k="v"/><rect id="r4"/></g>
|
||||
<circle id="c5" class="a x" data-k="w" style="stroke: s !important"/>
|
||||
</g>
|
||||
<text id="a" class="a"><tspan id="t6" class="x">t</tspan><tspan id="t7">u</tspan></text>
|
||||
<rect id="r8" class="z"/>
|
||||
</svg>"#;
|
||||
assert_same_as_every_rule(svg);
|
||||
with_doc(svg, |doc, st| {
|
||||
let own = |id: &str, name: &str| st.own(by_id(doc, id), name);
|
||||
assert_eq!(own("a", "fill").as_deref(), Some("f2"), "#a beats rect.a");
|
||||
assert_eq!(own("r2", "fill").as_deref(), Some("f4"), "rect.a beats g rect");
|
||||
assert_eq!(own("r2", "stroke").as_deref(), Some("s5"), "classes apart by a tab and a line feed, one repeated");
|
||||
assert_eq!(own("r3", "fill").as_deref(), Some("f8"), "!important");
|
||||
assert_eq!(own("r3", "display").as_deref(), Some("none"));
|
||||
assert_eq!(own("r4", "fill").as_deref(), Some("f1"), "g rect beats rect");
|
||||
assert_eq!(own("r4", "font-size").as_deref(), Some("9px"));
|
||||
assert_eq!(own("c5", "fill").as_deref(), Some("f11"), "of rules alike, the last one");
|
||||
assert_eq!(own("c5", "stroke").as_deref(), Some("s"));
|
||||
assert_eq!(own("t7", "font-size").as_deref(), Some("8px"));
|
||||
assert_eq!(own("r8", "fill").as_deref(), Some("f9"));
|
||||
});
|
||||
}
|
||||
|
||||
/// An element's candidates hold each rule once, however often the element repeats a class.
|
||||
#[test]
|
||||
fn repeated_classes_give_each_rule_once() {
|
||||
with_doc(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>.a { fill: x } .b { stroke: y } .a.b { opacity: 0.5 } rect { fill: z }</style><rect id="r" class="a a b a b"/></svg>"#,
|
||||
|doc, st| assert_eq!(st.index.candidates(by_id(doc, "r")), [0, 1, 2, 3]),
|
||||
);
|
||||
}
|
||||
|
||||
/// A compound selector over a few tags, ids, classes and attributes.
|
||||
fn arb_compound() -> impl Strategy<Value = String> {
|
||||
let tag = prop::sample::select(vec!["", "*", "g", "rect", "text", "tspan"]);
|
||||
let id = prop::sample::select(vec!["", "#a", "#b"]);
|
||||
let classes = prop::collection::vec(prop::sample::select(vec![".a", ".b", ".c"]), 0..3);
|
||||
let attr = prop::sample::select(vec!["", "[data-k]", "[data-k=v]"]);
|
||||
(tag, id, classes, attr).prop_map(|(t, i, c, a)| match format!("{t}{i}{}{a}", c.concat()) {
|
||||
s if s.is_empty() => "*".to_string(),
|
||||
s => s,
|
||||
})
|
||||
}
|
||||
|
||||
/// One to three compounds joined by descendant and child combinators.
|
||||
fn arb_selector() -> impl Strategy<Value = String> {
|
||||
(arb_compound(), prop::collection::vec((prop::sample::select(vec![" ", " > ", ">"]), arb_compound()), 0..3))
|
||||
.prop_map(|(first, rest)| rest.into_iter().fold(first, |s, (comb, c)| format!("{s}{comb}{c}")))
|
||||
}
|
||||
|
||||
/// A rule of one or two selectors that sets one to three properties, some of them `!important`.
|
||||
fn arb_rule() -> impl Strategy<Value = String> {
|
||||
let decl = (prop::sample::select(vec!["fill", "stroke", "display"]), 0..1000u16, any::<bool>())
|
||||
.prop_map(|(name, v, important)| format!("{name}: v{v}{}", if important { " !important" } else { "" }));
|
||||
(prop::collection::vec(arb_selector(), 1..3), prop::collection::vec(decl, 1..4))
|
||||
.prop_map(|(selectors, decls)| format!("{} {{ {} }}", selectors.join(", "), decls.join("; ")))
|
||||
}
|
||||
|
||||
/// An element that may have an id, classes (some repeated, apart by spaces, tabs and line feeds)
|
||||
/// and a `data-k` or `style` attribute, with up to four children on each of the `depth` levels
|
||||
/// below it.
|
||||
fn arb_element(depth: u32) -> BoxedStrategy<String> {
|
||||
let tag = prop::sample::select(vec!["g", "rect", "text", "tspan"]);
|
||||
let id = prop::sample::select(vec!["", r#" id="a""#, r#" id="b""#, r#" id="c""#]);
|
||||
let classes = prop::collection::vec((prop::sample::select(vec![" ", "	", " "]), prop::sample::select(vec!["a", "b", "c", "x"])), 0..4)
|
||||
.prop_map(|v| match v.iter().map(|(gap, c)| format!("{gap}{c}")).collect::<String>() {
|
||||
s if s.is_empty() => s,
|
||||
s => format!(r#" class="{s}""#),
|
||||
});
|
||||
let more = prop::sample::select(vec!["", r#" data-k="v""#, r#" data-k="w""#, r#" style="fill: s""#, r#" style="stroke: s !important""#]);
|
||||
let children = if depth == 0 { Just(vec![]).boxed() } else { prop::collection::vec(arb_element(depth - 1), 0..5).boxed() };
|
||||
(tag, id, classes, more, children).prop_map(|(t, i, c, m, ch)| format!("<{t}{i}{c}{m}>{}</{t}>", ch.concat())).boxed()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
/// Random style sheets give each element of a random document what testing every rule
|
||||
/// gives it.
|
||||
#[test]
|
||||
fn random_sheets_give_what_testing_every_rule_gives(rules in prop::collection::vec(arb_rule(), 0..24), body in prop::collection::vec(arb_element(3), 1..6)) {
|
||||
assert_same_as_every_rule(&format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><style>{}</style>{}</svg>"#, rules.join("\n"), body.concat()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within
|
||||
/// 20 s.
|
||||
#[test]
|
||||
fn styling_50000_elements_with_50000_rules_finishes_within_20_s() {
|
||||
const N: usize = 50_000;
|
||||
let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect();
|
||||
let rects: String = (0..N).map(|i| format!(r#"<rect class="c{i}"/>"#)).collect();
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><style>{rules}</style>{rects}</svg>"#);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let doc = roxmltree::Document::parse(&svg).unwrap();
|
||||
let st = Styles::new(&doc);
|
||||
let fills: Vec<Option<String>> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect();
|
||||
tx.send(fills).ok();
|
||||
});
|
||||
let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s"));
|
||||
assert_eq!(fills.len(), N);
|
||||
for (i, fill) in fills.iter().enumerate() {
|
||||
assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_shorthand_sets_its_longhands() {
|
||||
let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense");
|
||||
|
|
|
|||
|
|
@ -497,6 +497,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
|
||||
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
|
|
|
|||
Loading…
Reference in New Issue