SVG import: a <use> of a text passes its text properties on to the text

The element a <use> shows inherits from the <use> rather than from where it
is defined (SVG 2, https://www.w3.org/TR/SVG2/struct.html#UseElement). A
text shown by a <use> now takes the font, size, weight, fill, stroke and
other text properties the <use> or its parents set, in clip paths too,
while the text's own properties still win. Uses with the same parent and
properties share one text. A <use> inside the text it shows is ignored for
inheritance, and texts built for <use> elements stop at 1 MiB of text with
a warning.
This commit is contained in:
dotCooCoo
2026-11-12 05:24:22 -08:00
parent 81247fdae9
commit 1bf09bf72e
3 changed files with 338 additions and 37 deletions
+262 -37
View File
@@ -247,8 +247,9 @@ 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.
// The slot of each text, by element, for the `<use>` elements below.
let mut slot_of = HashMap::new();
let mut inst = Instances::default();
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}'"));
// CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement):
@@ -275,20 +276,22 @@ pub(super) fn prepare<'s>(
// 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);
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true);
}
} else {
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints);
placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true);
}
copied = r.end;
edits.push((r, out));
slot_of.insert(t.id(), index);
slots.texts.push(text);
inst.paints.push(paints);
}
if slots.texts.is_empty() {
return (Cow::Borrowed(svg), slots);
}
clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings);
clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings);
text_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings);
edits.sort_by_key(|(r, _)| r.start);
let mut out = String::with_capacity(svg.len());
let mut copied = 0;
@@ -332,6 +335,7 @@ fn clip_uses(
cx: &Ctx,
slot_of: &HashMap<roxmltree::NodeId, usize>,
slots: &mut TextSlots,
inst: &mut Instances,
edits: &mut Vec<(Range<usize>, String)>,
warnings: &mut Vec<String>,
) {
@@ -343,18 +347,16 @@ fn clip_uses(
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(&shared) = slot_of.get(&t.id()) 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 i = style_t.map_or(shared, |s| use_slot(cx, style_xml, slots, inst, shared, s, style_u, warnings));
let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue };
let placed = use_placement(cx, u, style_u);
let text_xf = match style_t {
Some(s) => own_transform(&cx.css, s),
None => t.attribute("transform").unwrap_or("").to_string(),
@@ -386,6 +388,180 @@ fn clip_uses(
}
}
/// Replace each `<use>` outside clip paths that shows a text it passes other properties on to by a
/// group holding that text's placeholder as the `<use>` shows it ([`use_slot`]). Every other
/// `<use>` of a text shows the text's own placeholder.
#[allow(clippy::too_many_arguments)]
fn text_uses(
svg: &str,
xml: &roxmltree::Document,
style_xml: &roxmltree::Document,
cx: &Ctx,
slot_of: &HashMap<roxmltree::NodeId, usize>,
slots: &mut TextSlots,
inst: &mut Instances,
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)
&& !n.ancestors().any(|a| a.is_element() && a.tag_name().name() == "clipPath")
};
if !xml.descendants().any(|n| is_use(&n)) {
return;
}
let (ids, style_ids) = (first_ids(xml), first_ids(style_xml));
for (u, style_u) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)) {
let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue };
let Some(t) = ids.get(id) else { continue };
let Some(&shared) = slot_of.get(&t.id()) else { continue };
let Some(style_t) = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()) else { continue };
let Some(q) = qualifier(svg, u) else { continue };
let i = use_slot(cx, style_xml, slots, inst, shared, style_t, style_u, warnings);
let (Some(text), Some(paints)) = (slots.texts.get(i), inst.paints.get(i)) else { continue };
if i == shared {
continue;
}
let mut out = String::new();
use_group(&mut out, q, cx, u, style_u);
placeholder(&mut out, q, &cx.css, *t, style_t, &slots.prefix, i, text.marker, paints, false);
let _ = write!(out, "</{q}g>");
edits.push((u.range(), out));
}
}
/// A text's slot, the parent of a `<use>` showing it and the properties that `<use>` sets itself.
type UseKey = (usize, Option<roxmltree::NodeId>, Vec<(String, String)>);
/// The slots made for texts as `<use>` elements show them ([`use_slot`]).
#[derive(Default)]
struct Instances {
/// The `url(#…)` paints of each slot, by index.
paints: Vec<Vec<String>>,
/// The slot made for each [`UseKey`]; `None` where the text's own slot serves.
memo: HashMap<UseKey, Option<usize>>,
/// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`].
work: usize,
}
/// The most nodes and text bytes [`use_slot`] builds texts from, all `<use>` elements together
/// (the file is untrusted). Past it, a `<use>` shows its text with the text's own properties.
const MAX_INSTANCE_WORK: usize = 1 << 20;
/// The slot of text `style_t` (slot `shared`) as `<use>` `style_u` shows it. The text inherits
/// from the `<use>` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise
/// the result is `shared`. `<use>` elements with one parent that set the same properties share one.
#[allow(clippy::too_many_arguments)]
fn use_slot(
cx: &Ctx,
style_xml: &roxmltree::Document,
slots: &mut TextSlots,
inst: &mut Instances,
shared: usize,
style_t: XNode,
style_u: XNode,
warnings: &mut Vec<String>,
) -> usize {
// A `<use>` inside the text it shows would make the text inherit from itself.
if style_u.ancestors().any(|a| a == style_t) {
return shared;
}
let placing = |name: &str| matches!(name, "id" | "x" | "y" | "width" | "height" | "href" | "transform" | "style");
let mut own: Vec<(String, String)> = style_u
.attributes()
.filter(|a| a.namespace().is_none() && !placing(a.name()))
.map(|a| (a.name().to_string(), a.value().to_string()))
.collect();
own.extend(cx.css.declared(style_u).iter().filter(|(k, _)| k.as_str() != "transform").map(|(k, v)| (format!("style {k}"), v.clone())));
own.sort();
let key = (shared, style_u.parent_element().map(|p| p.id()), own);
if let Some(slot) = inst.memo.get(&key) {
return slot.unwrap_or(shared);
}
let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
let work = if inst.work < MAX_INSTANCE_WORK {
style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::<usize>()
} else {
0
};
inst.work = inst.work.saturating_add(work);
if work == 0 || inst.work > MAX_INSTANCE_WORK {
inst.work = MAX_INSTANCE_WORK;
let w = format!(
"more than {} KiB of text shown through <use> elements: the rest show their text without the properties the <use> passes on",
MAX_INSTANCE_WORK >> 10
);
if !warnings.contains(&w) {
warnings.push(w);
}
return shared;
}
let shown = cx.instance(style_t, style_u);
let mut found = vec![];
let built = if style_t.tag_name().name() == "flowRoot" {
shown.build_flow(style_t, &label, &mut found)
} else {
shown.build(style_xml, style_t, &label, &mut found)
};
let differs = |(text, paints): &(PendingText, Vec<String>)| {
slots.texts.get(shared).is_none_or(|old| old.obj != text.obj || old.servers != text.servers || old.under != text.under)
|| inst.paints.get(shared) != Some(paints)
};
let slot = built.filter(differs).map(|(text, paints)| {
for w in found {
if !warnings.contains(&w) {
warnings.push(w);
}
}
slots.texts.push(text);
inst.paints.push(paints);
slots.texts.len() - 1
});
inst.memo.insert(key, slot);
slot.unwrap_or(shared)
}
/// The transform `<use>` `u` places what it shows with: its own, then its `x`/`y` offset (in its
/// font size). `style_u` supplies its styles.
fn use_placement(cx: &Ctx, u: XNode, style_u: XNode) -> String {
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));
format!("{} translate({x} {y})", own_transform(&cx.css, style_u))
}
/// Write the start tag of the group that takes `<use>` `u`'s place: its attributes in no namespace
/// other than its link, place and size, and as its `style` the properties rules and its `style` set
/// on it (from `style_u`), then its placement ([`use_placement`]) and its non-inherited properties
/// and `visibility`, so no rule that matches the group changes it.
fn use_group(out: &mut String, q: &str, cx: &Ctx, u: XNode, style_u: XNode) {
let _ = write!(out, "<{q}g");
let placing = |name: &str| matches!(name, "style" | "href" | "x" | "y" | "width" | "height" | "transform");
for a in u.attributes().filter(|a| a.namespace().is_none() && !placing(a.name())) {
let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value()));
}
let declared = cx.css.declared(style_u);
let mut props: Vec<_> = declared.iter().filter(|(k, _)| k.as_str() != "transform").collect();
props.sort();
let mut style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect();
let _ = write!(style, "transform:{};", use_placement(cx, u, style_u));
for (p, initial) in [
("opacity", "1"),
("clip-path", "none"),
("mask", "none"),
("filter", "none"),
("mix-blend-mode", "normal"),
("isolation", "auto"),
("display", "inline"),
] {
let _ = write!(style, "{p}:{};", cx.css.own(style_u, p).as_deref().unwrap_or(initial));
}
let _ = write!(style, "visibility:{}", cx.css.prop(style_u, "visibility").as_deref().unwrap_or("visible"));
let _ = write!(out, " style=\"{}\">", xml_escape(&style));
}
/// 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) {
@@ -455,11 +631,23 @@ fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, 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`.
/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`, with the text's
/// id when `with_id`.
#[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]) {
fn placeholder(
out: &mut String,
q: &str,
css: &Styles,
t: XNode,
style_t: XNode,
prefix: &str,
index: usize,
b: Rect,
paints: &[String],
with_id: bool,
) {
let _ = write!(out, "<{q}g");
for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] {
for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"].into_iter().skip(usize::from(!with_id)) {
if let Some(v) = t.attribute(a) {
let _ = write!(out, " {a}=\"{}\"", xml_escape(v));
}
@@ -488,11 +676,15 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode
/// Computed text properties of the document's elements.
struct Ctx {
css: Styles,
css: Rc<Styles>,
/// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]).
dpi: f64,
/// Viewport size in user units (the base of `x`/`y` percentages).
viewport: (f64, f64),
/// For the styles of one `<use>` instance: the element the `<use>` shows and the `<use>`. The
/// shown element inherits from the `<use>` rather than from its parent (SVG 2 §5.6,
/// https://www.w3.org/TR/SVG2/struct.html#UseElement).
host: Option<(roxmltree::NodeId, roxmltree::NodeId)>,
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
@@ -501,9 +693,10 @@ struct Ctx {
impl Ctx {
fn new(xml: &roxmltree::Document, dpi: f64) -> Self {
let mut cx = Self {
css: Styles::new(xml),
css: Rc::new(Styles::new(xml)),
dpi,
viewport: (100.0, 100.0),
host: None,
sizes: RefCell::default(),
weights: RefCell::default(),
elems: RefCell::default(),
@@ -517,6 +710,37 @@ impl Ctx {
cx
}
/// The styles of element `shown` as `<use>` `u` shows it.
fn instance(&self, shown: XNode, u: XNode) -> Self {
Self {
css: self.css.clone(),
dpi: self.dpi,
viewport: self.viewport,
host: Some((shown.id(), u.id())),
sizes: RefCell::default(),
weights: RefCell::default(),
elems: RefCell::default(),
}
}
/// The element `n` inherits from: its parent, or the `<use>` for the element it shows.
fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option<XNode<'a, 'i>> {
match self.host {
Some((shown, u)) if n.id() == shown => n.document().get_node(u),
_ => n.parent_element(),
}
}
/// Element `n` and the elements it inherits from, nearest first.
fn lineage<'a, 'i>(&self, n: XNode<'a, 'i>) -> impl Iterator<Item = XNode<'a, 'i>> {
std::iter::successors(Some(n), |a| self.parent(*a))
}
/// An inherited property: the nearest specified value other than `inherit`.
fn prop(&self, n: XNode, name: &str) -> Option<String> {
self.lineage(n).find_map(|a| self.css.own(a, name).filter(|v| v != "inherit"))
}
/// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative
/// to `em` (the font size) and percentages to `pct`.
fn length(&self, v: &str, em: f64, pct: f64) -> Option<f64> {
@@ -559,7 +783,7 @@ impl Ctx {
if let Some(s) = self.sizes.borrow().get(&n.id()) {
return *s;
}
let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p));
let parent = self.parent(n).map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p));
let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent);
self.sizes.borrow_mut().insert(n.id(), s);
s
@@ -570,7 +794,7 @@ impl Ctx {
if let Some(w) = self.weights.borrow().get(&n.id()) {
return *w;
}
let parent = n.parent_element().map_or(400, |p| self.weight(p));
let parent = self.parent(n).map_or(400, |p| self.weight(p));
let w = match self.css.own(n, "font-weight").as_deref() {
Some("normal") => 400,
Some("bold") => 700,
@@ -594,12 +818,12 @@ impl Ctx {
/// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve.
fn paint(&self, n: XNode, prop: &str) -> (Paint, Option<String>) {
let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None };
let Some(v) = self.css.prop(n, prop) else { return (initial, None) };
let Some(v) = self.prop(n, prop) else { return (initial, None) };
match svgtypes::Paint::from_str(&v) {
Ok(svgtypes::Paint::None) => (Paint::None, None),
Ok(svgtypes::Paint::Color(c)) => (solid(c), None),
Ok(svgtypes::Paint::CurrentColor) => {
(self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None)
(self.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None)
}
Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)),
_ => (initial, None),
@@ -613,7 +837,7 @@ impl Ctx {
}
let css = &self.css;
let mut st = CharStyle::default();
if let Some(f) = css.prop(n, "font-family") {
if let Some(f) = self.prop(n, "font-family") {
let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string();
if !fam.is_empty() {
st.font_family = fam;
@@ -621,45 +845,46 @@ impl Ctx {
}
let size = self.font_size(n);
st.size = size;
let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
let italic = self.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
st.font_style = style_name(self.weight(n), italic);
// Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited.
let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k));
let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v));
st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default();
st.font_axes = self.lineage(n).find_map(own).unwrap_or_default();
let (fill, mut fill_url) = self.paint(n, "fill");
st.fill = fill;
let (stroke, mut stroke_url) = self.paint(n, "stroke");
if !stroke.is_none() || stroke_url.is_some() {
st.stroke = stroke;
st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0);
st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() {
st.stroke_width = self.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0);
st.stroke_cap = match self.prop(n, "stroke-linecap").as_deref() {
Some("round") => LineCap::Round,
Some("square") => LineCap::Square,
_ => LineCap::Butt,
};
st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() {
st.stroke_join = match self.prop(n, "stroke-linejoin").as_deref() {
Some("round") => LineJoin::Round,
Some("bevel") => LineJoin::Bevel,
_ => LineJoin::Miter,
};
st.stroke_miter_limit =
css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0));
let pattern: Vec<f64> = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect());
let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0);
self.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0));
let pattern: Vec<f64> =
self.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect());
let offset = self.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0);
st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed);
}
if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size))
if let Some(v) = self.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size))
&& size > 0.0
{
st.tracking = v / size * 1000.0;
}
if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size))
if let Some(v) = self.prop(n, "kerning").and_then(|v| self.length(&v, size, size))
&& size > 0.0
{
st.kerning = Some(v / size * 1000.0);
}
if let Some(d) = css.prop(n, "text-decoration") {
if let Some(d) = self.prop(n, "text-decoration") {
st.underline = d.contains("underline");
st.strikethrough = d.contains("line-through");
}
@@ -672,11 +897,11 @@ impl Ctx {
};
let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) };
st.baseline_shift = parent + own;
if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
(st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None);
}
let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0);
let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order;
let word_spacing = self.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0);
let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order;
let at = |k| order.iter().position(|o| *o == k);
let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill);
let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first });
@@ -974,7 +1199,7 @@ impl Ctx {
.collect();
let under = stroke_under(&mut cells, label, warnings);
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() {
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() {
Some("middle") => 0.5,
Some("end") => 1.0,
_ => 0.0,
@@ -987,7 +1212,7 @@ impl Ctx {
None
}
});
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings);
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
let mut breaks: Vec<Break> = vec![];
@@ -1059,7 +1284,7 @@ impl Ctx {
}
// SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first
// strong character: pinned (on every paragraph) when that would read as right to left.
if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft));
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight));
+14
View File
@@ -183,6 +183,20 @@ fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() {
assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}");
}
#[test]
fn a_use_of_a_text_in_a_clip_path_passes_its_properties_on_to_the_text() {
let d = open(
r##"<defs><text id="t" x="0" y="0">HI</text></defs>
<clipPath id="c" font-weight="bold"><use href="#t" x="20" y="150" font-size="100"/></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!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size);
assert_eq!(t.runs[0].style.font_style, "Bold");
assert!(clip_bounds(clip).height() > 50.0, "{:?}", clip_bounds(clip));
}
#[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
+62
View File
@@ -191,6 +191,68 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() {
assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9);
}
/// SVG 2 §5.6 (https://www.w3.org/TR/SVG2/struct.html#UseElement): the element a `<use>` shows
/// inherits from the `<use>`, not from where it is defined.
#[test]
fn a_use_of_a_text_passes_its_properties_on_to_the_text() {
let d = open(
r##"<defs font-size="30"><text id="t" y="50">HI</text></defs>
<use href="#t" font-size="100" fill="red"/><g font-weight="bold"><use href="#t" x="200"/></g>
<use href="#t" y="200" opacity="0.5" style="font-size:20px"/>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 3, "{t:?}");
let s: Vec<CharStyle> = t.iter().map(|t| t.first_style()).collect();
assert!(close(s[0].size, 100.0, 1e-9), "{}", s[0].size);
assert_eq!(s[0].fill, Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)));
assert!(close(s[1].size, 12.0, 1e-9), "the defaults, not the <defs>'s 30: {}", s[1].size);
assert_eq!(s[1].font_style, "Bold");
assert_eq!(s[1].fill, Paint::solid(vectorcraft_color::Color::BLACK));
assert!(close(s[2].size, 20.0, 1e-9), "{}", s[2].size);
assert!(t[0].xf.translation().to_point().distance(Point::new(0.0, 50.0)) < 1e-9, "{:?}", t[0].xf);
assert!(t[1].xf.translation().to_point().distance(Point::new(200.0, 50.0)) < 1e-9, "{:?}", t[1].xf);
assert!(t[2].xf.translation().to_point().distance(Point::new(0.0, 250.0)) < 1e-9, "{:?}", t[2].xf);
// The `<use>`'s opacity stays on the object it becomes.
let faded: Vec<&Node> = art(&d).into_iter().filter(|n| (n.opacity - 0.5).abs() < 1e-6).collect();
assert_eq!(faded.len(), 1, "{faded:?}");
// The text's own properties win over the `<use>`'s.
let d = open(r##"<defs><text id="t" y="50" font-size="20" fill="blue">HI</text></defs><use href="#t" font-size="100" fill="red"/>"##);
let s = only_text(&d).first_style();
assert!(close(s.size, 20.0, 1e-9), "{}", s.size);
assert_eq!(s.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255)));
}
/// A `<use>` inside the text it shows does not make the text inherit from itself.
#[test]
fn a_use_inside_the_text_it_shows_opens() {
let d = open(r##"<text id="t" y="50" font-size="20">HI<use href="#t" font-size="100"/><tspan><use href="#t"/></tspan></text>"##);
assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d));
}
/// Past 1 MiB of text built for `<use>` elements, the rest show their text with its own properties.
#[test]
fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() {
let long = "a".repeat(350_000);
let uses: String = (1..=4).map(|i| format!(r##"<use href="#t" font-size="{}"/>"##, i * 10)).collect();
let (d, w) = import_with_report(&svg(&format!(r##"<defs><text id="t" y="50">{long}</text></defs>{uses}"##))).unwrap();
let sizes: Vec<f64> = texts(&d).iter().map(|t| t.first_style().size).collect();
assert_eq!(sizes.len(), 4, "{sizes:?}");
assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}");
assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}");
assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through <use> elements")).count(), 1, "{w:?}");
}
/// Many `<use>` elements of one text that inherit the same properties each get it.
#[test]
fn many_uses_of_a_text_in_one_group_all_take_its_properties() {
let uses: String = (0..200).map(|i| format!(r##"<use href="#t" x="{i}"/>"##)).collect();
let d = open(&format!(r##"<defs><text id="t" y="50">HI</text></defs><g font-size="40">{uses}</g>"##));
let t = texts(&d);
assert_eq!(t.len(), 200);
assert!(t.iter().all(|t| close(t.first_style().size, 40.0, 1e-9)));
assert!(t[199].xf.translation().to_point().distance(Point::new(199.0, 50.0)) < 1e-9, "{:?}", t[199].xf);
}
#[test]
fn text_path_becomes_type_on_a_path() {
let d = open(