From 6b159467a2e0c34df6b33b2f50fea02bedb64659 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 00:06:57 -0700 Subject: [PATCH] SVG import: tspan line breaks follow the baseline gap (none on the same baseline, bounded far gaps) Text import turns the gap between an absolutely positioned and the previous line into that many line breaks, with two problems: - At least one break was added even when the tspan sits on the current baseline. Styled or kerned runs written with x and y on every tspan (as Illustrator writes them) were split into one line per run: "Hello world" came back as "Hello \nworld". - The count had no upper bound. A tspan with y="1e15" (or any y far below its text) asked for ~10^14 '\n' characters, and opening the file aborted the app, the MCP server or `vectorcraft-cli` with "memory allocation failed" after eating all memory. A tspan less than half a line from the current baseline now adds no break, the gap is capped at 10 000 line breaks, and a non-finite gap counts as one; other gaps are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/import.rs | 15 ++++++++++++++- crates/svg/tests/roundtrip.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8bc4dcee..dc52f6c7 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -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 `` adds before itself. +const MAX_TSPAN_LINE_GAP: f64 = 10_000.0; + struct RunBuilder { runs: Vec, 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) { diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index f3159fe0..f9ea8c38 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -617,6 +617,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 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#" + firstthirdfar"#, + ); + 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#" + Hello worldnext"#, + ); + 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());