Merge pull request #8 from storybored379/pr/svg-import-tspan-line-gap

SVG import: tspan line breaks follow the baseline gap (none on the same baseline, bounded far gaps)
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-04 10:16:35 -04:00
commit 4be1e009ec
2 files changed
+46 -1

No files matched your search

+14 -1
View File
@@ -574,6 +574,9 @@ fn view_box_transform(root: XNode, w: f64, h: f64) -> Affine {
Affine::translate(((w - vb.w * s) * fx, (h - vb.h * s) * fy)) * Affine::scale(s) * Affine::translate((-vb.x, -vb.y))
}
/// Most line breaks one absolutely positioned `<tspan>` adds before itself.
const MAX_TSPAN_LINE_GAP: f64 = 10_000.0;
struct RunBuilder {
runs: Vec<TextRun>,
last_y: f64,
@@ -630,7 +633,17 @@ fn collect_runs(ctx: &TextCtx, el: XNode, rb: &mut RunBuilder) {
let lead = char_style(ctx, c).effective_leading().max(1e-6);
if let Some(y) = c.attribute("y") {
let y = first_number(Some(y));
let n = ((y - rb.last_y) / lead).round().max(1.0) as usize;
// One line break per line of baseline gap: none for a tspan on the same baseline
// (a styled or kerned run of the same line), at least one for a tspan placed
// higher up, and capped so that a far-off (or non-finite) y can't ask for billions.
let gap = ((y - rb.last_y) / lead).round();
let n = if !gap.is_finite() {
1
} else if (y - rb.last_y).abs() < lead * 0.5 {
0
} else {
gap.clamp(1.0, MAX_TSPAN_LINE_GAP) as usize
};
rb.newline(n);
rb.last_y = y;
} else if c.attribute("dy").is_some_and(|dy| first_number(Some(dy)) > 1e-9) {
+32
View File
@@ -637,6 +637,38 @@ fn import_text_tspans_and_css() {
assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#0000ff");
}
#[test]
fn import_tspan_far_below_is_bounded() {
// A <tspan y> far below the text's first line is a line break per line of gap, but not without
// limit: y="1e15" used to ask for ~7e13 line breaks and aborted the process (out of memory).
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<text x="5" y="20" font-size="12">first<tspan x="5" y="48.8">third</tspan><tspan x="5" y="2000000">far</tspan></text></svg>"#,
);
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
let text = t.plain_text();
// One blank line between "first" and "third" (auto leading 14.4 pt: two lines down).
assert!(text.starts_with("first\n\nthird\n"), "{text:?}");
assert!(text.ends_with("far"), "{text:?}");
let breaks = text.matches('\n').count();
assert!(breaks <= 10_002, "{breaks} line breaks");
}
#[test]
fn import_tspan_on_the_same_baseline_stays_on_the_line() {
// Styled runs positioned on the line's own baseline (as Illustrator writes them, with x and y on
// every tspan) are one line, not a line break before each run.
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100">
<text transform="translate(10 40)" font-size="20"><tspan x="0" y="0">Hello </tspan><tspan x="55.6" y="0" fill="red">world</tspan><tspan x="0" y="24">next</tspan></text></svg>"#,
);
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Hello world\nnext");
assert_eq!(t.runs.len(), 3);
}
#[test]
fn import_invalid_is_error() {
assert!(import("not svg").is_err());