mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 18:54:55 +00:00
SVG import: a clip path made of text clips by the text
A <text> in a <clipPath> was dropped with a warning, so a clip path made only of text had no content and the art it clipped was hidden. The clipPath content model in CSS Masking Module Level 1 lists text and use (https://www.w3.org/TR/css-masking-1/#ClipPathElement). The importer now clips by the text's glyph outlines, together with any shapes in the same clip path, and keeps clipPathUnits, the transforms, clip-rule, visibility, the conditional processing attributes and a clip path on the text. A <use> in a clip path that shows a text clips the same way, placed by the <use>; a clip path on the <use> applies in the <use>'s user space, and a reference goes to the first element with the id. When the clip path holds only one visible text, with no clip path of its own and no even-odd clip rule, the clipping path of the clip group is the text object with no fill or stroke, the same structure Make Clipping Mask produces for type. A text or a <use> with an id in a clip path can still be shown by a <use> outside it. Each text's outlines are laid out once, however many elements use the clip path. A transform set by a style rule or a style attribute on a text now places it, as it does a shape. Before, only the transform attribute was read, inside and outside clip paths.
This commit is contained in:
@@ -663,6 +663,26 @@ proptest! {
|
||||
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
|
||||
}
|
||||
|
||||
/// Clip paths made of text, of `<use>` elements showing text, and of both with shapes, each with
|
||||
/// hostile attributes, clipping art that may clip by them in turn.
|
||||
#[test]
|
||||
fn svg_text_clip_paths_never_panic(
|
||||
text in prop::collection::vec(arb_svg_attr(), 0..5),
|
||||
used in prop::collection::vec(arb_svg_attr(), 0..5),
|
||||
clip in prop::collection::vec(arb_svg_attr(), 0..3),
|
||||
body in prop::collection::vec(arb_svg_element(1), 0..3),
|
||||
) {
|
||||
let svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"><defs><text id=\"t\" font-size=\"20\"{}>ab<tspan dy=\"3\">c</tspan></text></defs><clipPath id=\"e\"{}><text y=\"30\"{}>Clip<tspan x=\"5\" y=\"60\">it</tspan></text><use href=\"#t\"{}/><rect width=\"5\" height=\"5\"/></clipPath><clipPath id=\"f\" clip-path=\"url(#e)\"><use xlink:href=\"#t\" x=\"9\"/></clipPath><rect width=\"90\" height=\"90\" clip-path=\"url(#e)\"/><g clip-path=\"url(#f)\">{}</g></svg>",
|
||||
text.concat(),
|
||||
clip.concat(),
|
||||
text.concat(),
|
||||
used.concat(),
|
||||
body.concat()
|
||||
);
|
||||
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_mutated_export_never_panics(
|
||||
cut in 0usize..20_000,
|
||||
|
||||
@@ -192,49 +192,58 @@ const MAX_CLIP_DEPTH: usize = 16;
|
||||
/// multiply the work at every level: without this, a few lines of SVG would never finish.
|
||||
const MAX_CLIP_READS: usize = 1024;
|
||||
|
||||
/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document.
|
||||
pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> {
|
||||
/// The shapes a text placeholder path in a clip path stands for (the text's glyph outlines), given
|
||||
/// the path and the transform `m` that maps its user space to the document; `None` for any other
|
||||
/// path.
|
||||
pub(super) type ClipText<'a> = &'a dyn Fn(&usvg::Path, Affine) -> Option<Vec<(BezPath, FillRule)>>;
|
||||
|
||||
/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document, with the
|
||||
/// glyph outlines `text` gives for the text in it.
|
||||
pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine, text: ClipText) -> Vec<(BezPath, FillRule)> {
|
||||
let mut reads = MAX_CLIP_READS;
|
||||
clip_shapes_depth(cp, ts, 0, &mut reads)
|
||||
clip_shapes_depth(cp, ts, 0, &mut reads, text)
|
||||
}
|
||||
|
||||
/// Recursively collect shapes for `cp` in document space up to `MAX_CLIP_DEPTH`, and while `reads`
|
||||
/// are left.
|
||||
fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize) -> Vec<(BezPath, FillRule)> {
|
||||
fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize, text: ClipText) -> Vec<(BezPath, FillRule)> {
|
||||
if depth > MAX_CLIP_DEPTH || *reads == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
*reads -= 1;
|
||||
fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, out: &mut Vec<(BezPath, FillRule)>) {
|
||||
fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, text: ClipText, out: &mut Vec<(BezPath, FillRule)>) {
|
||||
for c in g.children() {
|
||||
match c {
|
||||
usvg::Node::Group(g) => {
|
||||
let m_child = m * aff(g.transform());
|
||||
if let Some(child_cp) = g.clip_path() {
|
||||
let mut child_shapes = Vec::new();
|
||||
collect(g, m_child, depth + 1, reads, &mut child_shapes);
|
||||
collect(g, m_child, depth + 1, reads, text, &mut child_shapes);
|
||||
if !child_shapes.is_empty() {
|
||||
let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads);
|
||||
let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads, text);
|
||||
intersect_shapes(&child_shapes, &clip_paths, out);
|
||||
}
|
||||
} else {
|
||||
collect(g, m_child, depth, reads, out);
|
||||
collect(g, m_child, depth, reads, text, out);
|
||||
}
|
||||
}
|
||||
usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())),
|
||||
usvg::Node::Path(p) => match text(p, m) {
|
||||
Some(glyphs) => out.extend(glyphs),
|
||||
None => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut shapes = Vec::new();
|
||||
collect(cp.root(), ts * aff(cp.transform()), depth, reads, &mut shapes);
|
||||
collect(cp.root(), ts * aff(cp.transform()), depth, reads, text, &mut shapes);
|
||||
if !shapes.is_empty()
|
||||
&& depth > 0
|
||||
&& depth < MAX_CLIP_DEPTH
|
||||
&& *reads > 0
|
||||
&& let Some(parent_cp) = cp.clip_path()
|
||||
{
|
||||
let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads);
|
||||
let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads, text);
|
||||
let mut intersected = Vec::new();
|
||||
intersect_shapes(&shapes, &parent_shapes, &mut intersected);
|
||||
shapes = intersected;
|
||||
@@ -267,7 +276,7 @@ fn intersect_shapes(shapes: &[(BezPath, FillRule)], clips: &[(BezPath, FillRule)
|
||||
|
||||
/// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`,
|
||||
/// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.)
|
||||
pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool {
|
||||
pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine, text: ClipText) -> bool {
|
||||
let Some(b) = art.visual_bounds() else { return false };
|
||||
if cp.clip_path().is_some() {
|
||||
return false;
|
||||
@@ -275,7 +284,7 @@ pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) ->
|
||||
// Art touching the clip's edge isn't cut by it.
|
||||
let e = 1e-6 * (b.width() + b.height()).max(1e-9);
|
||||
let b = b.inflate(-e, -e);
|
||||
let shapes = clip_shapes(cp, ts);
|
||||
let shapes = clip_shapes(cp, ts, text);
|
||||
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)];
|
||||
corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c)))
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ impl Importer {
|
||||
_ => self.named("", NodeKind::Group { children: kids, clip: false })?,
|
||||
};
|
||||
// usvg clips a `<use>` of a symbol to its viewport: that only counts when it cuts the art.
|
||||
let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf));
|
||||
let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf, &|p, m| self.clip_text(p, m)));
|
||||
let name = match self.symbols.get(&sym) {
|
||||
Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(),
|
||||
Some(_) => return None,
|
||||
@@ -519,7 +519,10 @@ impl Importer {
|
||||
}
|
||||
|
||||
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
|
||||
let mut shapes = clip_shapes(cp, ts);
|
||||
if let Some(n) = self.clip_type(cp, ts) {
|
||||
return Some(n);
|
||||
}
|
||||
let mut shapes = clip_shapes(cp, ts, &|p, m| self.clip_text(p, m));
|
||||
let id = cp.id().to_string();
|
||||
if shapes.len() == 1 {
|
||||
let (bp, r) = shapes.pop()?;
|
||||
@@ -540,6 +543,53 @@ impl Importer {
|
||||
self.named(&id, NodeKind::Group { children, clip: false })
|
||||
}
|
||||
|
||||
/// The glyph outlines, in the document, of the text a clip placeholder path `p` stands for (see
|
||||
/// [`text`]), whose user space `m` maps to the document; nothing when the text is hidden. `None`
|
||||
/// when `p` isn't a placeholder: in a clip path only its id tells (usvg paints clip path content
|
||||
/// black).
|
||||
fn clip_text(&self, p: &usvg::Path, m: Affine) -> Option<Vec<(BezPath, FillRule)>> {
|
||||
if p.id().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let i = self.slots.find(p)?;
|
||||
let t = self.slots.texts.get(i)?;
|
||||
if !p.is_visible() {
|
||||
return Some(vec![]);
|
||||
}
|
||||
let mut bp = BezPath::clone(&*self.slots.outlines(i)?);
|
||||
bp.apply_affine(m * self.slots.inner(p) * t.obj.xf);
|
||||
let r = p.fill().map(|f| rule(f.rule())).unwrap_or_default();
|
||||
Some(if bp.elements().is_empty() { vec![] } else { vec![(bp, r)] })
|
||||
}
|
||||
|
||||
/// Clip path `cp` (on an element whose user space `ts` maps to the document) as live type, when
|
||||
/// all it holds is one visible text that clips by the non-zero rule and has no clip path of its
|
||||
/// own: the text object with no fill or stroke, the same structure Make Clipping Mask produces
|
||||
/// for type.
|
||||
fn clip_type(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
|
||||
let mut m = ts * aff(cp.transform());
|
||||
let mut kids = cp.root().children();
|
||||
while let [usvg::Node::Group(g)] = kids
|
||||
&& g.clip_path().is_none()
|
||||
{
|
||||
m *= aff(g.transform());
|
||||
kids = g.children();
|
||||
}
|
||||
let [usvg::Node::Path(p)] = kids else { return None };
|
||||
if p.id().is_empty() || !p.is_visible() || p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) {
|
||||
return None;
|
||||
}
|
||||
let mut obj = self.slots.texts.get(self.slots.find(p)?)?.obj.clone();
|
||||
obj.xf = m * self.slots.inner(p) * obj.xf;
|
||||
let grow = text::size_scale(&obj, obj.xf);
|
||||
for run in &mut obj.runs {
|
||||
run.style.fill = Paint::None;
|
||||
run.style.stroke = Paint::None;
|
||||
}
|
||||
text::fold_scale(&mut obj, grow);
|
||||
self.named(cp.id(), NodeKind::Text(Box::new(obj)))
|
||||
}
|
||||
|
||||
/// One clip path child: a path, or a compound path of its subpaths when it has several.
|
||||
fn clip_shape(&mut self, bp: &BezPath, r: FillRule, clipping: bool) -> Option<NodeKind> {
|
||||
let subs = PathData::from_bezpath(bp).subpaths;
|
||||
|
||||
+229
-22
@@ -10,6 +10,13 @@
|
||||
//! gradients and patterns in the text's user space; the importer swaps each placeholder for the
|
||||
//! [`PendingText`] it stands for.
|
||||
//!
|
||||
//! usvg accepts only shapes, `<text>` and `<use>` as the children of a `<clipPath>`, so a `<text>`
|
||||
//! there (and a `<use>` there that shows one) becomes a placeholder path instead, which the importer
|
||||
//! finds by its id (`{prefix}{i}-…`). The importer clips by the text's glyph outlines, or uses the
|
||||
//! text itself as live type. A `<text>` in a clip path that has an id also gets the usual
|
||||
//! placeholder group after its path, and a `<use>` there with an id keeps a copy in a `<defs>`
|
||||
//! after its path, for `<use>` elements outside the clip path.
|
||||
//!
|
||||
//! Characters keep SVG's per-character `x`/`y`/`dx`/`dy`/`rotate` lists: a character with an
|
||||
//! absolute `x` that moves down by at least half a line starts a new line (the leading becomes the
|
||||
//! line spacing), other vertical moves become baseline shift, horizontal moves become kerning (an
|
||||
@@ -166,14 +173,21 @@ pub(super) struct PendingText {
|
||||
pub(super) struct TextSlots {
|
||||
prefix: String,
|
||||
pub texts: Vec<PendingText>,
|
||||
/// The transform between a clip placeholder path's user space and its text's, by placeholder
|
||||
/// id, where the placeholder leaves it out (see [`clip_uses`]).
|
||||
inner: HashMap<String, Affine>,
|
||||
/// The glyph outlines of each text laid out so far, in text space, by index.
|
||||
outlines: RefCell<HashMap<usize, Rc<BezPath>>>,
|
||||
}
|
||||
|
||||
impl TextSlots {
|
||||
/// The text a placeholder's main path stands for: by its id, or inside `<use>` instances
|
||||
/// (where usvg drops ids) by its colour (the index) and its rectangle.
|
||||
/// The text a placeholder's main path stands for: by its id (`{prefix}{index}`, or
|
||||
/// `{prefix}{index}-{k}` for the clip placeholders), or inside `<use>` instances (where usvg
|
||||
/// drops ids) by its colour (the index) and its rectangle.
|
||||
pub fn find(&self, p: &usvg::Path) -> Option<usize> {
|
||||
if !p.id().is_empty() {
|
||||
return p.id().strip_prefix(&self.prefix)?.parse().ok();
|
||||
let rest = p.id().strip_prefix(&self.prefix)?;
|
||||
return rest.split_once('-').map_or(rest, |(i, _)| i).parse().ok();
|
||||
}
|
||||
let Some(usvg::Paint::Color(c)) = p.fill().map(|f| f.paint()) else { return None };
|
||||
if p.stroke().is_some() || p.data().segments().count() != 5 {
|
||||
@@ -185,6 +199,23 @@ impl TextSlots {
|
||||
let near = |a: f32, b: f64| (a as f64 - b).abs() <= 1e-3 * b.abs().max(1.0);
|
||||
(near(b.left(), m.x0) && near(b.top(), m.y0) && near(b.right(), m.x1) && near(b.bottom(), m.y1)).then_some(i)
|
||||
}
|
||||
|
||||
/// The transform from the user space of clip placeholder path `p` to the space of the text it
|
||||
/// stands for (the identity unless [`clip_uses`] left the text's transform out of it).
|
||||
pub fn inner(&self, p: &usvg::Path) -> Affine {
|
||||
self.inner.get(p.id()).copied().unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// The glyph outlines of text `i` in text space, laid out once however many clips use it.
|
||||
pub fn outlines(&self, i: usize) -> Option<Rc<BezPath>> {
|
||||
if let Some(bp) = self.outlines.borrow().get(&i) {
|
||||
return Some(bp.clone());
|
||||
}
|
||||
let t = self.texts.get(i)?;
|
||||
let bp = Rc::new(vectorcraft_text::layout(FontDb::global(), &t.obj).to_bezpath());
|
||||
self.outlines.borrow_mut().insert(i, bp.clone());
|
||||
Some(bp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace every `<text>` usvg would draw by a placeholder group (see the module docs).
|
||||
@@ -200,10 +231,11 @@ pub(super) fn prepare<'s>(
|
||||
while xml.descendants().any(|n| n.attribute("id").is_some_and(|id| id.starts_with(&prefix))) {
|
||||
prefix.insert(0, '_');
|
||||
}
|
||||
let mut slots = TextSlots { prefix, texts: vec![] };
|
||||
let mut slots = TextSlots { prefix, ..TextSlots::default() };
|
||||
let style_xml = original_xml.unwrap_or(xml);
|
||||
let cx = Ctx::new(style_xml, dpi);
|
||||
let mut out = String::new();
|
||||
// The replacements, in source order: each element's range and what takes its place.
|
||||
let mut edits: Vec<(Range<usize>, String)> = vec![];
|
||||
let mut copied = 0;
|
||||
let is_text = |n: &XNode| {
|
||||
n.is_element()
|
||||
@@ -211,52 +243,227 @@ pub(super) fn prepare<'s>(
|
||||
&& n.tag_name().namespace() == Some(SVG_NS)
|
||||
&& !n.ancestors().any(|a| a.has_tag_name("style"))
|
||||
};
|
||||
// The slot of each text, by element, for the `<use>` elements in clip paths below.
|
||||
let mut slot_of = HashMap::new();
|
||||
for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) {
|
||||
let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
|
||||
if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") {
|
||||
// CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement):
|
||||
// text is clip path content only as a child of the `<clipPath>`.
|
||||
let clip_child = in_clip_path(t);
|
||||
if !clip_child && t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") {
|
||||
warnings.push(format!("{label} in a clip path ignored"));
|
||||
continue;
|
||||
}
|
||||
let r = t.range();
|
||||
// The element's qualified name (`text` or `svg:text`), so the placeholder lands in the same
|
||||
// namespace; skip anything whose source range isn't the element itself (entity expansions).
|
||||
let Some(qname) = svg
|
||||
.get(r.start..r.end)
|
||||
.and_then(|s| s.strip_prefix('<'))
|
||||
.map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(q) = qualifier(svg, t) else { continue };
|
||||
let flowed = t.tag_name().name() == "flowRoot";
|
||||
let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
|
||||
if cx.css.own(style_t, "display").as_deref() == Some("none") || r.start < copied {
|
||||
continue;
|
||||
}
|
||||
let built = if flowed { cx.build_flow(style_t, &label, warnings) } else { cx.build(style_xml, style_t, &label, warnings) };
|
||||
let Some((text, paints)) = built else { continue };
|
||||
out.push_str(&svg[copied..r.start]);
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, slots.texts.len(), text.marker, &paints);
|
||||
let mut out = String::new();
|
||||
let index = slots.texts.len();
|
||||
if clip_child {
|
||||
let id = format!("{}{index}-c", slots.prefix);
|
||||
let props = clip_props(&cx.css, Some(style_t), None);
|
||||
clip_placeholder(&mut out, q, &id, &own_transform(&cx.css, style_t), &props, t, text.marker);
|
||||
// A `<use>` elsewhere can still show the text by its id: the clip path leaves out this
|
||||
// group (a group isn't clip path content), but the `<use>` finds it.
|
||||
if t.attribute("id").is_some() {
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints);
|
||||
}
|
||||
} else {
|
||||
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints);
|
||||
}
|
||||
copied = r.end;
|
||||
edits.push((r, out));
|
||||
slot_of.insert(t.id(), index);
|
||||
slots.texts.push(text);
|
||||
}
|
||||
if slots.texts.is_empty() {
|
||||
return (Cow::Borrowed(svg), slots);
|
||||
}
|
||||
out.push_str(&svg[copied..]);
|
||||
clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings);
|
||||
edits.sort_by_key(|(r, _)| r.start);
|
||||
let mut out = String::with_capacity(svg.len());
|
||||
let mut copied = 0;
|
||||
for (r, s) in edits {
|
||||
if r.start < copied {
|
||||
continue;
|
||||
}
|
||||
out.push_str(svg.get(copied..r.start).unwrap_or(""));
|
||||
out.push_str(&s);
|
||||
copied = r.end;
|
||||
}
|
||||
out.push_str(svg.get(copied..).unwrap_or(""));
|
||||
(Cow::Owned(out), slots)
|
||||
}
|
||||
|
||||
/// Is `n` a child of a `<clipPath>`?
|
||||
fn in_clip_path(n: XNode) -> bool {
|
||||
n.parent_element().is_some_and(|p| p.tag_name().name() == "clipPath" && p.tag_name().namespace() == Some(SVG_NS))
|
||||
}
|
||||
|
||||
/// The namespace prefix of element `n` as the source writes it (empty, or `svg:` and the like), so
|
||||
/// what replaces it lands in the same namespace. `None` when its source range isn't the element
|
||||
/// itself (entity expansions).
|
||||
fn qualifier<'s>(svg: &'s str, n: XNode) -> Option<&'s str> {
|
||||
let r = n.range();
|
||||
let qname = svg
|
||||
.get(r.start..r.end)
|
||||
.and_then(|s| s.strip_prefix('<'))
|
||||
.map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""))?;
|
||||
qname.strip_suffix(n.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':'))
|
||||
}
|
||||
|
||||
/// Replace each `<use>` in a clip path that shows a text by the clip placeholder of that text,
|
||||
/// placed by the `<use>` (usvg drops ids and paints clip path content black, so the text a
|
||||
/// placeholder group stands for couldn't be found again inside one).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn clip_uses(
|
||||
svg: &str,
|
||||
xml: &roxmltree::Document,
|
||||
style_xml: &roxmltree::Document,
|
||||
cx: &Ctx,
|
||||
slot_of: &HashMap<roxmltree::NodeId, usize>,
|
||||
slots: &mut TextSlots,
|
||||
edits: &mut Vec<(Range<usize>, String)>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let is_use = |n: &XNode| n.is_element() && n.tag_name().name() == "use" && n.tag_name().namespace() == Some(SVG_NS) && in_clip_path(*n);
|
||||
if !xml.descendants().any(|n| is_use(&n)) {
|
||||
return;
|
||||
}
|
||||
let (ids, style_ids) = (first_ids(xml), first_ids(style_xml));
|
||||
for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() {
|
||||
let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue };
|
||||
let Some(t) = ids.get(id) else { continue };
|
||||
let Some(&i) = slot_of.get(&t.id()) else { continue };
|
||||
let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue };
|
||||
let Some(q) = qualifier(svg, u) else { continue };
|
||||
// An undisplayed `<use>` adds nothing to the clip (usvg leaves it out as it is).
|
||||
if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") {
|
||||
continue;
|
||||
}
|
||||
let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name());
|
||||
let em = cx.font_size(style_u);
|
||||
let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0);
|
||||
let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1));
|
||||
let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u));
|
||||
let text_xf = match style_t {
|
||||
Some(s) => own_transform(&cx.css, s),
|
||||
None => t.attribute("transform").unwrap_or("").to_string(),
|
||||
};
|
||||
// One element can carry one clip path. The text's own clip path is in the text's user space
|
||||
// and wins; a clip path of the `<use>` is in the `<use>`'s, so the text's transform moves
|
||||
// to the importer instead.
|
||||
let clipped = |n: XNode| cx.css.own(n, "clip-path").is_some_and(|v| v.trim() != "none");
|
||||
let id = format!("{}{i}-{k}", slots.prefix);
|
||||
let transform = if style_t.is_some_and(clipped) {
|
||||
if clipped(style_u) {
|
||||
let label = u.attribute("id").map_or_else(|| "a use of a text".to_string(), |id| format!("'{id}'"));
|
||||
warnings.push(format!("clip path of {label} in a clip path ignored: the text's own applies"));
|
||||
}
|
||||
format!("{placed} {text_xf}")
|
||||
} else {
|
||||
slots.inner.insert(id.clone(), parse_transform(Some(&text_xf)));
|
||||
placed
|
||||
};
|
||||
let props = clip_props(&cx.css, style_t, Some(style_u));
|
||||
let mut out = String::new();
|
||||
clip_placeholder(&mut out, q, &id, &transform, &props, u, marker);
|
||||
// Another `<use>` can still show this one by its id: a copy of it stays in a `<defs>`, which
|
||||
// the clip path leaves out, with the properties style rules gave it written out.
|
||||
if u.attribute("id").is_some() {
|
||||
use_copy(&mut out, q, &cx.css, u, style_u);
|
||||
}
|
||||
edits.push((u.range(), out));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a copy of `<use>` `u` in a `<defs>`: its attributes in no namespace, its link as `href`,
|
||||
/// and the properties set on it by rules and its `style` (from `style_u`) as its `style`.
|
||||
fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) {
|
||||
let _ = write!(out, "<{q}defs><{q}use");
|
||||
for a in u.attributes().filter(|a| a.namespace().is_none() && !matches!(a.name(), "style" | "href")) {
|
||||
let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value()));
|
||||
}
|
||||
if let Some(h) = href(u) {
|
||||
let _ = write!(out, " href=\"{}\"", xml_escape(h));
|
||||
}
|
||||
let declared = css.declared(style_u);
|
||||
let mut props: Vec<_> = declared.iter().collect();
|
||||
props.sort();
|
||||
let style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect();
|
||||
let _ = write!(out, " style=\"{}\"/></{q}defs>", xml_escape(&style));
|
||||
}
|
||||
|
||||
/// The identity transform, as a `transform` value usvg reads.
|
||||
const IDENTITY: &str = "matrix(1 0 0 1 0 0)";
|
||||
|
||||
/// The transform of element `n`: a `transform` declaration in a rule or its `style` (which the
|
||||
/// prepass has written in SVG syntax), else its `transform` attribute; empty when it has none.
|
||||
fn own_transform(css: &Styles, n: XNode) -> String {
|
||||
css.own(n, "transform").filter(|v| !v.eq_ignore_ascii_case("none")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Each id of `doc` with the first element that has it (a reference goes to that one, as in usvg).
|
||||
fn first_ids<'a, 'i>(doc: &'a roxmltree::Document<'i>) -> HashMap<&'a str, XNode<'a, 'i>> {
|
||||
let mut ids = HashMap::new();
|
||||
for n in doc.descendants() {
|
||||
if let Some(id) = n.attribute("id") {
|
||||
ids.entry(id).or_insert(n);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// The properties of clip path content that the placeholder of text `t` keeps, as a `style` value:
|
||||
/// its `clip-path` (or that of the `<use>` `u` showing it, when the text has none), and its
|
||||
/// computed `clip-rule` and `visibility`, inherited from `u` when there is one. Every value is
|
||||
/// written out, so no style rule that matches the placeholder path applies to it.
|
||||
fn clip_props(css: &Styles, t: Option<XNode>, u: Option<XNode>) -> String {
|
||||
let set = |n: XNode, p: &str| css.own(n, p).filter(|v| v.trim() != "inherit");
|
||||
let clip = |n: XNode| css.own(n, "clip-path").filter(|v| v.trim() != "none");
|
||||
let mut style = format!("clip-path:{};", t.and_then(clip).or_else(|| u.and_then(clip)).unwrap_or_else(|| "none".into()));
|
||||
for (p, initial) in [("clip-rule", "nonzero"), ("visibility", "visible")] {
|
||||
let v = t.and_then(|t| set(t, p)).or_else(|| u.or(t).and_then(|n| css.prop(n, p))).unwrap_or_else(|| initial.into());
|
||||
let _ = write!(style, "{p}:{v};");
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
/// Write the placeholder of a text in a clip path: a path over the text's bounds `b` with id `id`,
|
||||
/// transform `transform`, properties `props` ([`clip_props`]) and the conditions of element `n`.
|
||||
/// usvg drops a group in a clip path and paints clip path content black whatever its fill, so the
|
||||
/// placeholder is a path, and the importer finds it by its id ([`TextSlots::find`]).
|
||||
fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: &str, n: XNode, b: Rect) {
|
||||
let _ = write!(out, "<{q}path id=\"{}\"", xml_escape(id));
|
||||
for a in ["systemLanguage", "requiredFeatures", "requiredExtensions"] {
|
||||
if let Some(v) = n.attribute(a) {
|
||||
let _ = write!(out, " {a}=\"{}\"", xml_escape(v));
|
||||
}
|
||||
}
|
||||
// In `style`, so no rule that matches the path moves it.
|
||||
let transform = if transform.trim().is_empty() { IDENTITY } else { transform };
|
||||
let _ = write!(out, " style=\"transform:{};{}display:inline\"", xml_escape(transform), xml_escape(props));
|
||||
let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0);
|
||||
}
|
||||
|
||||
/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) {
|
||||
let _ = write!(out, "<{q}g");
|
||||
for a in ["id", "transform", "systemLanguage", "requiredFeatures", "requiredExtensions"] {
|
||||
for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] {
|
||||
if let Some(v) = t.attribute(a) {
|
||||
let _ = write!(out, " {a}=\"{}\"", xml_escape(v));
|
||||
}
|
||||
}
|
||||
// The text's own non-inherited properties, overriding any rule that matches the `<g>`.
|
||||
let mut style = String::new();
|
||||
// The text's own non-inherited properties, overriding any rule that matches the `<g>`. The
|
||||
// transform too: a `transform` declaration in a rule or `style` beats the attribute.
|
||||
let transform = own_transform(css, style_t);
|
||||
let mut style = format!("transform:{};", if transform.is_empty() { IDENTITY } else { &transform });
|
||||
for (p, initial) in
|
||||
[("opacity", "1"), ("clip-path", "none"), ("mask", "none"), ("filter", "none"), ("mix-blend-mode", "normal"), ("isolation", "auto")]
|
||||
{
|
||||
|
||||
@@ -78,8 +78,11 @@
|
||||
//! 64 periods).
|
||||
//! * `<image>` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG
|
||||
//! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link.
|
||||
//! * Text lines after the first start at the first line's x; text in a clip path is ignored;
|
||||
//! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways.
|
||||
//! * Text lines after the first start at the first line's x; absolute positions inside type on a
|
||||
//! path are ignored; vertical text sets every glyph sideways.
|
||||
//! * A `<text>` in a `<clipPath>`, or a `<use>` there that shows one, clips by its glyph outlines.
|
||||
//! When a clip path holds only one text, and that text has no clip path of its own and no
|
||||
//! even-odd clip rule, the clip group's clipping path is the text as live type that paints nothing.
|
||||
//!
|
||||
//! ## Import text
|
||||
//!
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! SVG import of clip paths made of text (CSS Masking Module Level 1 §6.1,
|
||||
//! https://www.w3.org/TR/css-masking-1/#ClipPathElement: `<text>` and `<use>` are clip path
|
||||
//! content).
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_svg::{ExportOptions, export, import, import_with_report};
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">{body}</svg>"#)
|
||||
}
|
||||
|
||||
fn open(body: &str) -> Document {
|
||||
import(&svg(body)).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
|
||||
/// The top-level objects of the first layer.
|
||||
fn top(d: &Document) -> &[std::sync::Arc<Node>] {
|
||||
d.layers[0].children().unwrap()
|
||||
}
|
||||
|
||||
/// The clip group `n` is, with its clipping path and the art it clips.
|
||||
fn clip_group(n: &Node) -> (&Node, &[std::sync::Arc<Node>]) {
|
||||
let NodeKind::Group { children, clip: true } = &n.kind else { panic!("not a clip group: {:?}", n.kind) };
|
||||
let (first, rest) = children.split_first().unwrap();
|
||||
(first, rest)
|
||||
}
|
||||
|
||||
/// The bounds of the region a clipping path clips to.
|
||||
fn clip_bounds(clip: &Node) -> Rect {
|
||||
let (bp, _) = vectorcraft_effects::clip_outline(clip).expect("the clipping path clips to something");
|
||||
bp.bounding_box()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_clip_path_clips_by_the_text_as_live_type() {
|
||||
let d = open(
|
||||
r##"<defs><clipPath id="word"><text x="20" y="150" font-size="100" fill="green" stroke="blue">HI</text></clipPath></defs>
|
||||
<rect id="art" width="400" height="300" fill="red" clip-path="url(#word)"/>"##,
|
||||
);
|
||||
let objects = top(&d);
|
||||
assert_eq!(objects.len(), 1, "{objects:?}");
|
||||
let (clip, art) = clip_group(&objects[0]);
|
||||
assert_eq!(art.len(), 1);
|
||||
assert_eq!(art[0].name.as_deref(), Some("art"));
|
||||
// The clipping path is the text object with no fill or stroke, as after Make Clipping Mask.
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("the clipping path is {:?}", clip.kind) };
|
||||
assert_eq!(t.plain_text(), "HI");
|
||||
assert!(t.runs.iter().all(|r| r.style.fill == Paint::None && r.style.stroke == Paint::None));
|
||||
assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size);
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf);
|
||||
let p = clip.clip_paint();
|
||||
assert!(p.fill.is_none() && p.stroke.is_none(), "a clip path paints nothing");
|
||||
// It clips to the glyphs: inside the line, not the whole rectangle.
|
||||
let b = clip_bounds(clip);
|
||||
assert!(b.x0 >= 20.0 - 1e-6 && b.x1 < 220.0 && b.y1 <= 150.0 + 1e-6 && b.y0 > 50.0 && b.width() > 40.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_shapes_in_one_clip_path_clip_to_their_union() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><rect x="300" y="10" width="50" height="40"/><text x="20" y="150" font-size="100">O</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let shapes = clip.clip_shapes(None);
|
||||
assert_eq!(shapes.len(), 2, "the rectangle and the glyph outlines: {shapes:?}");
|
||||
let rect = shapes[0].0.bounding_box();
|
||||
assert!(near(rect.x0, 300.0, 1e-6) && near(rect.y1, 50.0, 1e-6), "{rect:?}");
|
||||
let glyphs = shapes[1].0.bounding_box();
|
||||
assert!(glyphs.x0 >= 20.0 - 1e-6 && glyphs.x1 < 120.0 && glyphs.y1 < 160.0 && glyphs.y0 > 50.0, "{glyphs:?}");
|
||||
let b = clip_bounds(clip);
|
||||
assert!(near(b.x1, 350.0, 1e-6) && near(b.y0, 10.0, 1e-6), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_clip_path_keeps_its_units_and_transforms() {
|
||||
// The clip path's box units and the text's own transform place the type.
|
||||
let d = open(
|
||||
r##"<clipPath id="c" clipPathUnits="objectBoundingBox">
|
||||
<text transform="translate(0.5 0) scale(0.5 1)" x="0" y="0.8" font-size="0.8">I</text></clipPath>
|
||||
<rect x="100" y="100" width="200" height="100" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind);
|
||||
let b = clip_bounds(clip);
|
||||
// The box maps x 0.5…1 to 200…300 and y 0…1 to 100…200.
|
||||
assert!(b.x0 >= 200.0 - 1e-6 && b.x1 <= 300.0 && b.y0 >= 100.0 && b.y1 <= 180.0 + 1e-6 && b.height() > 30.0, "{b:?}");
|
||||
// The clip path's transform moves it, as it moves a shape in it.
|
||||
let d = open(
|
||||
r##"<clipPath id="c" transform="translate(100 20)"><text x="20" y="150" font-size="100">HI</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_clip_path_with_a_clip_rule_or_a_clip_of_its_own_clips_by_its_outlines() {
|
||||
// The text's own clip path cuts its glyphs: the clip keeps their part inside the rectangle.
|
||||
let d = open(
|
||||
r##"<clipPath id="half"><rect width="400" height="120"/></clipPath>
|
||||
<clipPath id="c"><text x="20" y="150" font-size="100" clip-path="url(#half)">HI</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
assert!(!matches!(clip.kind, NodeKind::Text(_)), "outlines, as the clip of the text cuts them");
|
||||
let b = clip_bounds(clip);
|
||||
assert!(b.y1 <= 120.0 + 1e-6 && b.y0 > 50.0 && b.x0 >= 20.0 - 1e-6, "{b:?}");
|
||||
// An even-odd text clip keeps its rule on the outlines.
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text x="20" y="150" font-size="100" clip-rule="evenodd">O</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let shapes = clip.clip_shapes(None);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tspans_and_type_on_a_path_clip_too() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text font-size="60"><tspan x="10" y="80">A</tspan><tspan x="10" y="200">B</tspan></text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(t.plain_text().contains('A') && t.plain_text().contains('B'), "{:?}", t.plain_text());
|
||||
let b = clip_bounds(clip);
|
||||
assert!(b.y0 < 80.0 && b.y1 > 150.0, "both lines clip: {b:?}");
|
||||
|
||||
let d = open(
|
||||
r##"<defs><path id="arc" d="M20 200 Q200 20 380 200"/></defs>
|
||||
<clipPath id="c"><text font-size="40"><textPath href="#arc">Around</textPath></text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind);
|
||||
assert!(clip_bounds(clip).width() > 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() {
|
||||
let d = open(
|
||||
r##"<defs><text id="t" x="0" y="0" font-size="100">HI</text></defs>
|
||||
<clipPath id="c"><use href="#t" x="20" y="150"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(20.0, 150.0)) < 1e-9, "{:?}", t.xf);
|
||||
// Font-relative positions count in the `<use>`'s font size; an undisplayed `<use>` adds nothing.
|
||||
let d = open(
|
||||
r##"<defs><text id="t" x="0" y="0" font-size="100">HI</text></defs>
|
||||
<clipPath id="c" font-size="40"><use href="#t" x="2em" y="3em"/><use href="#t" x="300" y="50" display="none"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("only the displayed use: {:?}", clip.kind) };
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(80.0, 120.0)) < 1e-9, "{:?}", t.xf);
|
||||
// The same text drawn as well as used in a clip path.
|
||||
let d = open(
|
||||
r##"<text id="t" x="0" y="100" font-size="50">HI</text>
|
||||
<clipPath id="c"><use xlink:href="#t" transform="translate(200 0)"/><rect width="10" height="10"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let objects = top(&d);
|
||||
assert_eq!(objects.len(), 2, "{objects:?}");
|
||||
assert!(matches!(objects[0].kind, NodeKind::Text(_)));
|
||||
let (clip, _) = clip_group(&objects[1]);
|
||||
let shapes = clip.clip_shapes(None);
|
||||
assert_eq!(shapes.len(), 2, "{shapes:?}");
|
||||
let glyphs = shapes[0].0.bounding_box();
|
||||
assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() {
|
||||
// The text's transform moves its glyphs to x 200…; the `<use>`'s clip keeps x < 250 of its own
|
||||
// user space, which the text's transform doesn't move.
|
||||
let d = open(
|
||||
r##"<defs><clipPath id="k"><rect width="250" height="300"/></clipPath>
|
||||
<text id="t" transform="translate(200 0)" x="0" y="150" font-size="100">HI</text></defs>
|
||||
<clipPath id="c"><use href="#t" clip-path="url(#k)"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let b = clip_bounds(clip);
|
||||
assert!(b.x0 >= 200.0 - 1e-6 && near(b.x1, 250.0, 1e-6), "{b:?}");
|
||||
// Without a clip on the `<use>`, the text's transform still places the type.
|
||||
let d = open(
|
||||
r##"<defs><text id="t" transform="translate(200 0)" x="0" y="150" font-size="100">HI</text></defs>
|
||||
<clipPath id="c"><use href="#t" transform="translate(0 10)"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(200.0, 160.0)) < 1e-9, "{:?}", t.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_css_transform_places_a_text_in_a_clip_path() {
|
||||
let d = open(
|
||||
r##"<style>.moved { transform: translate(100px, 20px) }</style>
|
||||
<clipPath id="c"><text class="moved" x="20" y="150" font-size="100">HI</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(120.0, 170.0)) < 1e-9, "{:?}", t.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_transforms_place_a_use_of_a_text_in_a_clip_path() {
|
||||
let d = open(
|
||||
r##"<defs><text id="t" style="transform: translate(5px, 0)" x="0" y="0" font-size="100">HI</text></defs>
|
||||
<clipPath id="c"><use href="#t" style="transform: translate(20px, 150px)"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) };
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(25.0, 150.0)) < 1e-9, "{:?}", t.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_use_refers_to_the_first_element_with_its_id() {
|
||||
let d = open(
|
||||
r##"<defs><rect id="t" x="300" width="50" height="50"/><text id="t" y="150" font-size="100">HI</text></defs>
|
||||
<clipPath id="c"><use href="#t"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
assert!(!matches!(clip.kind, NodeKind::Text(_)), "the rectangle clips: {:?}", clip.kind);
|
||||
let b = clip_bounds(clip);
|
||||
assert!(near(b.x0, 300.0, 1e-6) && near(b.x1, 350.0, 1e-6) && near(b.y1, 50.0, 1e-6), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_in_a_clip_path_can_be_shown_by_a_use_elsewhere() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text id="w" x="20" y="150" font-size="100">HI</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>
|
||||
<use href="#w" y="100"/>"##,
|
||||
);
|
||||
let objects = top(&d);
|
||||
assert_eq!(objects.len(), 2, "{objects:?}");
|
||||
let (clip, _) = clip_group(&objects[0]);
|
||||
assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind);
|
||||
let mut shown = vec![];
|
||||
objects[1].walk(&mut |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
shown.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(shown, ["HI"], "the use shows the text: {:?}", objects[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_use_in_a_clip_path_can_be_shown_by_another_use() {
|
||||
let d = open(
|
||||
r##"<defs><text id="t" x="0" y="100" font-size="50">HI</text></defs>
|
||||
<clipPath id="c"><use id="u" href="#t" x="20"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>
|
||||
<use href="#u" y="100"/>"##,
|
||||
);
|
||||
let objects = top(&d);
|
||||
assert_eq!(objects.len(), 2, "{objects:?}");
|
||||
let (clip, _) = clip_group(&objects[0]);
|
||||
assert!(matches!(clip.kind, NodeKind::Text(_)), "{:?}", clip.kind);
|
||||
let mut shown = vec![];
|
||||
objects[1].walk(&mut |n: &Node| match &n.kind {
|
||||
NodeKind::Text(t) => shown.push(t.plain_text()),
|
||||
NodeKind::Path { .. } => shown.push("a path".into()),
|
||||
_ => {}
|
||||
});
|
||||
assert_eq!(shown, ["HI"], "the other use shows the text: {:?}", objects[1]);
|
||||
// A rule that styles the `<use>` where it stands still applies to what the other use shows.
|
||||
let d = open(
|
||||
r##"<style>clipPath > use { transform: translate(40px, 0) }</style>
|
||||
<defs><text id="t" x="0" y="100" font-size="50">HI</text></defs>
|
||||
<clipPath id="c"><use id="u" xlink:href="#t" x="20"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>
|
||||
<use href="#u" y="100"/>"##,
|
||||
);
|
||||
let objects = top(&d);
|
||||
let mut at = vec![];
|
||||
objects[1].walk(&mut |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
at.push(t.xf.translation().to_point());
|
||||
}
|
||||
});
|
||||
assert!(at.len() == 1 && at[0].distance(Point::new(60.0, 200.0)) < 1e-9, "{at:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_rules_for_paths_do_not_reach_a_text_in_a_clip_path() {
|
||||
let d = open(
|
||||
r##"<style>path { visibility: hidden } clipPath path { clip-rule: evenodd }</style>
|
||||
<clipPath id="c"><text x="20" y="150" font-size="100">HI</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
assert!(matches!(clip.kind, NodeKind::Text(_)), "the visible, non-zero text: {:?}", clip.kind);
|
||||
// The text's own values, and those it inherits, still apply.
|
||||
let d = open(
|
||||
r##"<style>path { clip-rule: nonzero }</style>
|
||||
<clipPath id="c" clip-rule="evenodd"><text x="20" y="150" font-size="100">O</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let shapes = clip.clip_shapes(None);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert_eq!(shapes[0].1, vectorcraft_geom::FillRule::EvenOdd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_text_alone_in_a_clip_path_is_not_live_type() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text x="20" y="150" font-size="100" visibility="hidden">HI</text></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let objects = top(&d);
|
||||
let clips = objects.iter().filter_map(|n| match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => children.first(),
|
||||
_ => None,
|
||||
});
|
||||
for clip in clips {
|
||||
assert!(!matches!(clip.kind, NodeKind::Text(_)), "a hidden text clips nothing: {:?}", clip.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_left_out_by_its_conditions_clips_nothing() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text x="20" y="150" font-size="100" systemLanguage="zz">HI</text><rect width="10" height="10"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let b = clip_bounds(clip);
|
||||
assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_clip_on_a_symbol_instance_cuts_art_between_the_letters() {
|
||||
// The art sits in the space between the two letters, inside the text's box: the glyphs cut it,
|
||||
// so it stays clipped art instead of becoming an instance without the clip.
|
||||
let body = |clip: &str| {
|
||||
format!(
|
||||
r##"<defs><symbol id="s" overflow="visible"><rect x="140" y="200" width="4" height="4"/></symbol>
|
||||
<clipPath id="c">{clip}</clipPath></defs>
|
||||
<use href="#s" clip-path="url(#c)"/>"##
|
||||
)
|
||||
};
|
||||
let instances = |d: &Document| {
|
||||
let mut n = 0;
|
||||
d.walk(|node| n += usize::from(matches!(node.kind, NodeKind::SymbolInstance { .. })));
|
||||
n
|
||||
};
|
||||
let d = open(&body(r#"<text x="0" y="300" font-size="400">I I</text>"#));
|
||||
assert_eq!(instances(&d), 0, "{:?}", top(&d)[0].kind);
|
||||
assert!(matches!(top(&d)[0].kind, NodeKind::Group { clip: true, .. }));
|
||||
// A clip around the art leaves it whole: an instance.
|
||||
let d = open(&body(r#"<rect width="400" height="300"/>"#));
|
||||
assert_eq!(instances(&d), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_path_clipped_by_a_text_clip_path_nests() {
|
||||
let d = open(
|
||||
r##"<clipPath id="inner"><text x="20" y="150" font-size="100">HI</text></clipPath>
|
||||
<clipPath id="outer" clip-path="url(#inner)"><rect width="400" height="120"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#outer)"/>"##,
|
||||
);
|
||||
let (clip, art) = clip_group(&top(&d)[0]);
|
||||
assert!(matches!(clip.kind, NodeKind::Text(_)), "the outermost clip is the text: {:?}", clip.kind);
|
||||
let (inner, _) = clip_group(&art[0]);
|
||||
assert!(matches!(inner.kind, NodeKind::Path { clipping: true, .. }), "{:?}", inner.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_in_a_clip_path_clips_nothing() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text x="20" y="150" font-size="100" visibility="hidden">HI</text><rect width="10" height="10"/></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let (clip, _) = clip_group(&top(&d)[0]);
|
||||
let b = clip_bounds(clip);
|
||||
assert!(near(b.x1, 10.0, 1e-6) && near(b.y1, 10.0, 1e-6), "only the rectangle: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_further_down_in_a_clip_path_is_still_not_clip_path_content() {
|
||||
let body = r##"<clipPath id="c"><rect width="10" height="10"/><g><text y="50">No</text></g></clipPath>
|
||||
<rect width="400" height="300" clip-path="url(#c)"/>"##;
|
||||
let (_, warnings) = import_with_report(&svg(body)).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("in a clip path ignored")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_clip_survives_a_round_trip_through_svg_export() {
|
||||
let d = open(
|
||||
r##"<clipPath id="c"><text x="20" y="150" font-size="100">HI</text></clipPath>
|
||||
<rect width="400" height="300" fill="red" clip-path="url(#c)"/>"##,
|
||||
);
|
||||
let before = clip_bounds(clip_group(&top(&d)[0]).0);
|
||||
let again = import(&export(&d, &ExportOptions::default())).unwrap();
|
||||
let mut clips = vec![];
|
||||
again.walk(|n| {
|
||||
if let NodeKind::Group { children, clip: true } = &n.kind {
|
||||
clips.push(children[0].clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(clips.len(), 1);
|
||||
let after = clip_bounds(&clips[0]);
|
||||
let tol = 0.01 * before.width().max(before.height());
|
||||
assert!(
|
||||
near(after.x0, before.x0, tol) && near(after.x1, before.x1, tol) && near(after.y0, before.y0, tol) && near(after.y1, before.y1, tol),
|
||||
"{before:?} → {after:?}"
|
||||
);
|
||||
}
|
||||
@@ -108,6 +108,19 @@ fn text_keeps_its_z_order_between_shapes() {
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(5.0, 20.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_css_transform_places_text() {
|
||||
// A `transform` declaration in a rule or `style` beats the attribute, as on shapes.
|
||||
for body in [
|
||||
r#"<style>text { transform: translate(10px, 20px) }</style><text transform="translate(300 0)" y="20">Moved</text>"#,
|
||||
r#"<text style="transform: translate(10px, 20px)" transform="translate(300 0)" y="20">Moved</text>"#,
|
||||
] {
|
||||
let d = open(body);
|
||||
let t = only_text(&d);
|
||||
assert!(t.xf.translation().to_point().distance(Point::new(10.0, 40.0)) < 1e-9, "{body}: {:?}", t.xf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_its_parent_group_clip_and_mask() {
|
||||
let d = open(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
mod basic_shapes;
|
||||
mod blend_export;
|
||||
mod clip_text;
|
||||
mod colorized_images;
|
||||
mod colour_glyphs;
|
||||
mod container_export;
|
||||
|
||||
Reference in New Issue
Block a user