diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index c8bfe40a..d7762b50 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 0719bcc4..c5006190 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -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 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());