Merge pull request #1569 from switch87/feat/svg-flowroot-area-type

SVG import: Inkscape flowed text (flowRoot) becomes area type
This commit is contained in:
BFlat authored and GitHub committed 2026-10-13 11:26:57 +07:00
commit a20b0308b8
2 files changed
+82 -3

No files matched your search

+47 -3
View File
@@ -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,47 @@ impl Ctx {
}
}
/// Read one Inkscape `<flowRoot>` (SVG 1.2 flowed text, which browsers don't draw but
/// Inkscape writes) as area type: its frame the first `<rect>` of its `<flowRegion>`, a
/// paragraph per `<flowPara>`, styled as `<text>` is. Other regions are left out with a warning.
fn build_flow(&self, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
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<XNode> = 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::<String>().split_whitespace().collect::<Vec<_>>().join(" ")
};
let text = paras.iter().map(|p| collapse(*p)).collect::<Vec<_>>().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);
// A size, place or transform past what numbers hold (`width="1e999"`) has no frame to set type in.
if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) {
warnings.push(format!("{label}: flowed text (flowRoot) with a region too large to place was left out"));
return None;
}
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 `<text>`: the pending text and its `url(#…)` paints.
fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
+35
View File
@@ -594,3 +594,38 @@ 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 (`<flowRoot>`) becomes area type in its region's rectangle, a paragraph
/// per `<flowPara>`; 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#"<flowRoot style="font-size:16px;font-family:sans-serif"><flowRegion><rect x="20" y="30" width="100" height="160"/></flowRegion><flowPara>This is flowed text</flowPara><flowPara>Second <flowSpan>one</flowSpan></flowPara></flowRoot>
<flowRoot><flowRegion><path d="M0 0 L10 0 L5 10 Z"/></flowRegion><flowPara>Lost</flowPara></flowRoot>"#,
))
.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:?}");
}
/// Flowed text that can't be read is left out with a warning each, and never stops the import: no
/// region, a region too large for numbers, a flowRoot inside a flowRoot (read once, as the outer one).
#[test]
fn flowed_text_that_cannot_be_read_is_reported() {
let (d, w) = import_with_report(&svg(
r#"<flowRoot id="none"><flowPara>No region</flowPara></flowRoot>
<flowRoot id="huge"><flowRegion><rect width="1e999" height="10"/></flowRegion><flowPara>Huge</flowPara></flowRoot>
<flowRoot id="far"><flowRegion><rect x="1e308" width="1e308" height="10" transform="scale(1e308)"/></flowRegion><flowPara>Far</flowPara></flowRoot>
<flowRoot id="outer"><flowRegion><rect width="50" height="50"/></flowRegion><flowPara>Outer</flowPara><flowRoot><flowRegion><rect width="5" height="5"/></flowRegion><flowPara>inner</flowPara></flowRoot></flowRoot>"#,
))
.unwrap();
assert_eq!(only_text(&d).plain_text(), "Outer\ninner");
for id in ["none", "huge", "far"] {
assert!(w.iter().any(|w| w.contains(id) && w.contains("flowRoot")), "{id}: {w:?}");
}
}