From e794c1ac6355f995af139e9ca36957b6ba216afd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 03:29:14 +0200 Subject: [PATCH] SVG import: Inkscape flowed text (flowRoot) becomes area type (SVG 1.2 flowed text, which Inkscape writes for text typed into a frame) was dropped without a word: usvg doesn't draw it. The text pass now reads it as area type: the first of its is the frame (with the rect's transform), each a paragraph (white space collapsed), styled through the same cascade as . Another kind of region leaves the text out with a warning. Fixes #1568 --- crates/svg/src/import/text.rs | 45 ++++++++++++++++++++++++++++++--- crates/svg/tests/import_text.rs | 18 +++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 23aa3c57..32dbde6c 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -175,7 +175,8 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war let cx = Ctx::new(xml, dpi); let mut out = String::new(); let mut copied = 0; - for t in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text" && n.tag_name().namespace() == Some(SVG_NS)) { + let is_text = |n: &XNode| n.is_element() && matches!(n.tag_name().name(), "text" | "flowRoot") && n.tag_name().namespace() == Some(SVG_NS); + for t in 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") { warnings.push(format!("{label} in a clip path ignored")); @@ -191,11 +192,13 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war else { continue; }; - let Some(q) = qname.strip_suffix("text").filter(|q| q.is_empty() || q.ends_with(':')) 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(t, "display").as_deref() == Some("none") || r.start < copied { continue; } - let Some((text, paints)) = cx.build(xml, t, &label, warnings) else { continue }; + let built = if flowed { cx.build_flow(t, &label, warnings) } else { cx.build(xml, t, &label, warnings) }; + let Some((text, paints)) = built else { continue }; out.push_str(&svg[copied..r.start]); placeholder(&mut out, q, &cx.css, t, &slots.prefix, slots.texts.len(), text.marker, &paints); copied = r.end; @@ -560,6 +563,42 @@ impl Ctx { } } + /// Read one Inkscape `` (SVG 1.2 flowed text, which browsers don't draw but + /// Inkscape writes) as area type: its frame the first `` of its ``, a + /// paragraph per ``, styled as `` is. Other regions are left out with a warning. + fn build_flow(&self, t: XNode, label: &str, warnings: &mut Vec) -> Option<(PendingText, Vec)> { + let (vw, vh) = self.viewport; + let region = t.children().find(|c| c.is_element() && c.tag_name().name() == "flowRegion"); + let rect = region.and_then(|r| r.children().find(|c| c.is_element() && c.tag_name().name() == "rect")); + let size = |n: XNode, a: &str, pct: f64| n.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct)); + let frame = rect.and_then(|r| { + let (w, h) = (size(r, "width", vw)?, size(r, "height", vh)?); + (w > 0.0 && h > 0.0).then(|| (r, Rect::new(0.0, 0.0, w, h))) + }); + let Some((r, frame)) = frame else { + warnings.push(format!("{label}: flowed text (flowRoot) without a rectangle region was left out")); + return None; + }; + let origin = Affine::translate((size(r, "x", vw).unwrap_or(0.0), size(r, "y", vh).unwrap_or(0.0))); + let paras: Vec = t.descendants().filter(|n| n.is_element() && n.tag_name().name() == "flowPara").collect(); + let collapse = |n: XNode| { + n.descendants().filter(XNode::is_text).filter_map(|c| c.text()).collect::().split_whitespace().collect::>().join(" ") + }; + let text = paras.iter().map(|p| collapse(*p)).collect::>().join("\n"); + if text.trim().is_empty() { + return None; + } + let style = self.elem(paras.first().copied().unwrap_or(t)).style.clone(); + let mut obj = TextObject::point(Point::ZERO, &text, style); + obj.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(frame) }; + obj.xf = parse_transform(r.attribute("transform")) * origin; + let servers = vec![(None, None); obj.runs.len()]; + let b = obj.xf.transform_rect_bbox(frame); + let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0)); + let name = t.attribute("id").unwrap_or("").to_string(); + Some((PendingText { name, obj, servers, paints: 0, non_scaling: false, under: None, marker }, Vec::new())) + } + /// Read one ``: the pending text and its `url(#…)` paints. fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec) -> Option<(PendingText, Vec)> { let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve"); diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index 56ec8636..3d587a56 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -594,3 +594,21 @@ fn the_font_shorthand_styles_type() { let style = st.font_style.to_ascii_lowercase(); assert!(style.contains("bold") && style.contains("italic"), "{style}"); } + +/// Inkscape's flowed text (``) becomes area type in its region's rectangle, a paragraph +/// per ``; another kind of region is left out with a warning, not silently. +#[test] +fn flowed_text_becomes_area_type() { + let (d, w) = import_with_report(&svg( + r#"This is flowed textSecond one + Lost"#, + )) + .unwrap(); + let t = only_text(&d); + assert_eq!(t.plain_text(), "This is flowed text\nSecond one"); + let TextKind::Area { frame } = &t.kind else { panic!("{:?}", t.kind) }; + let b = t.xf.transform_rect_bbox(frame.bounds().unwrap()); + assert!(close(b.x0, 20.0, 1e-6) && close(b.y0, 30.0, 1e-6) && close(b.x1, 120.0, 1e-6) && close(b.y1, 190.0, 1e-6), "{b:?}"); + assert!(close(t.runs[0].style.size * t.xf.determinant().abs().sqrt(), 16.0, 1e-6)); + assert!(w.iter().any(|w| w.contains("flowRoot")), "{w:?}"); +}