diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 52b6663906..afd719163f 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -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 `` elements in clip paths below. + // The slot of each text, by element, for the `` 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 `` 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 `` 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, slots: &mut TextSlots, + inst: &mut Instances, edits: &mut Vec<(Range, String)>, warnings: &mut Vec, ) { @@ -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 `` 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 `` outside clip paths that shows a text it passes other properties on to by a +/// group holding that text's placeholder as the `` shows it ([`use_slot`]). Every other +/// `` 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, + slots: &mut TextSlots, + inst: &mut Instances, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + 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, ""); + edits.push((u.range(), out)); + } +} + +/// A text's slot, the parent of a `` showing it and the properties that `` sets itself. +type UseKey = (usize, Option, Vec<(String, String)>); + +/// The slots made for texts as `` elements show them ([`use_slot`]). +#[derive(Default)] +struct Instances { + /// The `url(#…)` paints of each slot, by index. + paints: Vec>, + /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. + memo: HashMap>, + /// 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 `` elements together +/// (the file is untrusted). Past it, a `` 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 `` `style_u` shows it. The text inherits +/// from the `` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise +/// the result is `shared`. `` 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, +) -> usize { + // A `` 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::() + } 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 elements: the rest show their text without the properties the 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)| { + 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 `` `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 `` `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 `` `u` in a ``: 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, /// 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 `` instance: the element the `` shows and the ``. The + /// shown element inherits from the `` 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>, weights: RefCell>, elems: RefCell>>, @@ -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 `` `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 `` for the element it shows. + fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option> { + 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> { + 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 { + 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 { @@ -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) { 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::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); - let pattern: Vec = 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::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); + let pattern: Vec = + 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 = 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)); diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs index 07773dd5f8..a2dcb46156 100644 --- a/crates/svg/tests/it/clip_text.rs +++ b/crates/svg/tests/it/clip_text.rs @@ -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##"HI + + "##, + ); + 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 ``'s clip keeps x < 250 of its own diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 8b0d0338c6..87fe6cf340 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -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 `` shows +/// inherits from the ``, not from where it is defined. +#[test] +fn a_use_of_a_text_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let t = texts(&d); + assert_eq!(t.len(), 3, "{t:?}"); + let s: Vec = 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 '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 ``'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 ``'s. + let d = open(r##"HI"##); + 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 `` 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##"HI"##); + assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d)); +} + +/// Past 1 MiB of text built for `` 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##""##, i * 10)).collect(); + let (d, w) = import_with_report(&svg(&format!(r##"{long}{uses}"##))).unwrap(); + let sizes: Vec = 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 elements")).count(), 1, "{w:?}"); +} + +/// Many `` 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##""##)).collect(); + let d = open(&format!(r##"HI{uses}"##)); + 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(