mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
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:
+262
-37
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user