Type: frames and paths beyond 1,000,000,000 pt hold no text, and layout limits its search for room

Opening an EPS whose threaded area type frame has a control point at
x = 8e115 failed with an internal error ("capacity overflow").
`eps_text_frames_never_panic` in `import_fuzz` found it with
PROPTEST_CASES=20000. Text layout flattens a frame to within 0.1 pt,
and for such a curve kurbo 0.13.1's `flatten` calls `reserve` for
`usize::MAX` quadratic pieces. Laying out text around a wrap shape with
such a curve failed the same way. Laying out type on a path along such
a curve panicked in kurbo's `solve_itp` ("attempt to shift left with
overflow") in builds with overflow checks, and in a release build it
did not finish within 20 s. Laying out area type in a triangle frame
that comes to a point at its top, at y = 1e20, did not finish either:
adding a lead to a baseline that large leaves the baseline where it is.

Text layout now leaves out a frame, a text wrap shape or the path of
type on a path that has a coordinate beyond 1,000,000,000 pt either
way, control points included, or a NaN coordinate in an anchor point.
Such a frame or path holds no text, and such a wrap shape is ignored.
PDF export uses the same bound (`MAX_COORD` in
`crates/pdf/src/export.rs`).

Text layout also steps a line down a frame cell at most 100,000 times
to find room for it. In a cell taller than 100,000 leads, a step is the
cell's height divided by 100,000; in a shorter cell, a step is one
lead, as before. Before this change, in a dev build, with triangle
frames that come to a point at their top, laying out text in one
2,000,000,000 pt tall took 9.8 s, and laying out a paragraph whose
Space Before is -1e300 pt in one 1,000 pt tall did not finish within
20 s.
This commit is contained in:
dotCooCoo 2026-10-12 08:24:08 -07:00 committed by bflatastic
parent 2b34554ac8
commit 8754646784
3 changed files with 177 additions and 10 deletions

View File

@ -1838,6 +1838,24 @@ fn the_frames_fixture_reads_as_threaded_area_type_and_type_on_a_path() {
assert_eq!(l.doc.text_threads.len(), 1);
}
/// The smallest failing input `eps_text_frames_never_panic` found: the first frame's bottom edge
/// has a control point at x = 8e115 (`8e0115`). That frame holds no text, and the story flows on
/// into the second frame.
#[test]
fn a_text_frame_with_a_control_point_at_8e115_opens_with_its_story_in_the_next_frame() {
let text = mutate_text(&frames_document_text(), 1397, &[(191, '1'), (1586, 'e')]);
assert!(text.contains(" 8e0115 "));
let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&text, FRAME_OBJECTS)).unwrap();
let mut texts = vec![];
l.doc.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
assert_eq!(texts, ["", "A story in two frames\nand more", "On a path"], "{:?}", l.warnings);
survive("a frame with a control point at 8e115 pt", || Some(l.doc)).unwrap();
}
/// An EPS with a hidden text object whose story is in `document`.
fn text_eps(document: &str) -> Vec<u8> {
text_eps_of(document, "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n")

View File

@ -763,8 +763,27 @@ struct Region {
gap: f64,
}
/// Flattened closed polygons of a path.
fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
/// The largest magnitude a coordinate of a frame, a text wrap shape or the path of type on a path
/// may have (points), control points included. A frame or path with a larger coordinate, or a
/// coordinate that isn't finite, holds no text, and a text wrap shape with one is ignored.
const MAX_COORD: f64 = 1e9;
/// Are the coordinates of `path`, control points included, finite and within [`MAX_COORD`]?
fn in_range(path: &BezPath) -> bool {
let near = |p: &Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD;
path.elements().iter().all(|el| match el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => near(p),
PathEl::QuadTo(a, p) => near(a) && near(p),
PathEl::CurveTo(a, b, p) => near(a) && near(b) && near(p),
PathEl::ClosePath => true,
})
}
/// Flattened closed polygons of a path; `None` when it isn't [`in_range`].
fn polygons(path: &BezPath) -> Option<Vec<Vec<Point>>> {
if !in_range(path) {
return None;
}
let mut polys: Vec<Vec<Point>> = Vec::new();
kurbo::flatten(path, 0.1, |el| match el {
PathEl::MoveTo(p) => polys.push(vec![p]),
@ -776,7 +795,7 @@ fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
_ => {}
});
polys.retain(|p| p.len() >= 3);
polys
Some(polys)
}
/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`.
@ -844,9 +863,13 @@ fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
impl Region {
fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
let polys = polygons(path);
let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> =
wrap.iter().map(|w| (polygons(&w.path.to_bezpath()), w.wrap.offset.max(0.0), w.wrap.invert)).filter(|w| !w.0.is_empty()).collect();
// A frame out of range has no cells, and a wrap shape out of range is left out.
let Some(polys) = polygons(path) else { return vec![] };
let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> = wrap
.iter()
.filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert)))
.filter(|w| !w.0.is_empty())
.collect();
let bbox = path.bounding_box();
let rect = polys.len() == 1 && {
let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>().abs() * 0.5;
@ -992,6 +1015,10 @@ struct Pen<'r> {
next_top: Option<f64>,
}
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
/// in a cell taller than this many leads, a step is the cell's height divided by this.
const MAX_STEPS: usize = 100_000;
impl Pen<'_> {
/// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` =
/// the frame is full (overflow).
@ -1012,7 +1039,10 @@ impl Pen<'_> {
None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min),
Some(b) => self.next_baseline(b, est) + r.gap,
};
loop {
// The baseline moves down the cell a lead at a time, or a `MAX_STEPS`th of the cell's
// height when that is more, in at most `MAX_STEPS` steps.
let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64);
for _ in 0..=MAX_STEPS {
if baseline + est.desc > r.bottom() + 0.01 {
break;
}
@ -1020,13 +1050,13 @@ impl Pen<'_> {
if r.wraps.is_empty() {
match r.span(baseline - est.asc, baseline + est.desc) {
Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)),
_ => baseline += est.lead.max(1.0),
_ => baseline += step,
}
} else {
// Wrap objects can split a line: fill every span, left to right.
let mut spans: Vec<(f64, f64)> = r.spans(baseline - est.asc, baseline + est.desc).into_iter().filter(fits).collect();
if spans.is_empty() {
baseline += est.lead.max(1.0);
baseline += step;
continue;
}
let (a, b) = spans.remove(0);
@ -1752,7 +1782,8 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
}
}
let rtl = rtl.unwrap_or(false);
let ap = ArcPath::new(path);
// A path out of range holds no text, as a path with no length holds none.
let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() };
let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| {
let s = style_metrics(cx.db, cx.style_at(0));
(s.0, s.1)

View File

@ -212,6 +212,124 @@ fn area_flow_inside_circle_keeps_glyphs_inside() {
assert!(w(l.lines.len() / 2) > w(0));
}
/// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or
/// takes more than 20 s.
fn layout_soon(t: &TextObject) -> TextLayout {
let (tx, rx) = std::sync::mpsc::channel();
let (t, text) = (t.clone(), t.plain_text());
std::thread::spawn(move || tx.send(layout(db(), &t)).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}"))
}
/// Each line's baseline, left and right edges, and byte range.
fn line_places(l: &TextLayout) -> Vec<(f64, f64, f64, usize, usize)> {
l.lines.iter().map(|li| (li.baseline, li.x0, li.x1, li.start, li.end)).collect()
}
/// A frame or text wrap shape with a coordinate beyond a billion points either way, or one that
/// isn't finite, is left out: the frame holds no text, and the wrap shape is ignored.
#[test]
fn frames_and_wrap_shapes_beyond_a_billion_points_are_left_out() {
// A box from x0 to x1 and from 0 to `height` whose top edge has a control point at x = `far`,
// on the edge's own line.
let boxed = |x0: f64, x1: f64, height: f64, far: f64| {
let mut p = BezPath::new();
p.move_to((x0, 0.0));
p.curve_to((x0, 0.0), (far, 0.0), (x1, 0.0));
p.line_to((x1, height));
p.line_to((x0, height));
p.close_path();
p
};
let rectangle = layout(db(), &area("Hi there", style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left));
let within = layout_soon(&area_path("Hi there", style(12.0), &boxed(0.0, 100.0, 40.0, 1e9), Justify::Left));
assert_eq!(line_places(&within), line_places(&rectangle));
let mut beyond = [8e115, 2e9, f64::INFINITY].map(|far| boxed(0.0, 100.0, 40.0, far)).to_vec();
beyond.push(Rect::new(0.0, 0.0, 100.0, f64::NAN).to_path(0.1));
for frame in &beyond {
let l = layout_soon(&area_path("Hi there", style(12.0), frame, Justify::Left));
assert!(l.lines.is_empty() && l.glyphs.is_empty() && l.overflow, "{frame:?}");
}
// Text wrapped around the right half of a 300 by 200 frame.
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
let unwrapped = layout(db(), &t);
let mut wrapped = |path: BezPath| {
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&path), wrap: Default::default() }];
layout_soon(&t)
};
let half = wrapped(Rect::new(150.0, 0.0, 300.0, 200.0).to_path(0.1));
assert_ne!(line_places(&half), line_places(&unwrapped));
assert_eq!(line_places(&wrapped(boxed(150.0, 300.0, 200.0, 1e9))), line_places(&half));
for far in [8e115, 2e9, f64::INFINITY] {
assert_eq!(line_places(&wrapped(boxed(150.0, 300.0, 200.0, far))), line_places(&unwrapped), "{far}");
}
assert_eq!(line_places(&wrapped(Rect::new(150.0, 0.0, 300.0, f64::NAN).to_path(0.1))), line_places(&unwrapped));
}
/// Layout steps a line down a frame (or a row or column of one) at most 100,000 times to find room
/// for it: in a frame taller than 100,000 leads, a step is a 100,000th of the frame's height.
#[test]
fn tall_frames_are_searched_for_room_in_at_most_100000_steps() {
// A triangle from y = -1e9 to 1e9, `base` wide at the bottom.
let triangle = |base: f64| {
let mut p = BezPath::new();
p.move_to((0.0, -1e9));
p.line_to((base / 2.0, 1e9));
p.line_to((-base / 2.0, 1e9));
p.close_path();
p
};
// No line fits in this triangle: it is at most 0.5 pt wide, and a line needs a span wider than
// 1 pt or its ascent, whichever is more. A lead of 0.5 pt type counts as 1 pt.
let none = layout_soon(&area_path("Hi", style(0.5), &triangle(0.5), Justify::Left));
assert!(none.lines.is_empty() && none.overflow);
// This one is wide enough for 12 pt type from about y = -7.6e8 down.
let some = layout_soon(&area_path("Hi", style(12.0), &triangle(100.0), Justify::Left));
assert!(!some.overflow && some.lines.len() == 1);
// At the height of the line's top, the line's left and right edges lie inside the triangle.
let line = &some.lines[0];
let half_width = (line.baseline - line.ascent + 1e9) / 2e9 * 50.0;
assert!(line.x0 >= -half_width - 1e-6 && line.x1 <= half_width + 1e-6, "{line:?}");
}
/// A paragraph whose Space Before puts it 1e300 pt above a triangle frame that comes to a point at
/// its top overflows. At that height a step of one lead leaves the baseline where it is, and
/// layout takes at most 100,000 such steps.
#[test]
fn a_paragraph_spaced_far_above_its_frame_overflows() {
let mut triangle = BezPath::new();
triangle.move_to((0.0, 0.0));
triangle.line_to((500.0, 1000.0));
triangle.line_to((-500.0, 1000.0));
triangle.close_path();
let mut t = area_path("Hi\nthere", style(12.0), &triangle, Justify::Left);
let far = vectorcraft_doc::ParaStyle { space_before: -1e300, ..Default::default() };
t.set_paragraph_styles(vec![t.para.clone(), far]);
let l = layout_soon(&t);
assert!(l.overflow && l.lines.len() == 1, "{:?}", line_places(&l));
}
/// Type on a path whose path has a coordinate beyond a billion points either way, or one that isn't
/// finite, holds no text.
#[test]
fn type_on_a_path_beyond_a_billion_points_holds_no_text() {
// An S-shaped curve from (0, 0) to (3s, 0) with control points (s, s) and (2s, -s).
let on = |s: f64| {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.curve_to((s, s), (2.0 * s, -s), (3.0 * s, 0.0));
let mut t = TextObject::point(Point::ZERO, "On a path", style(8.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&p), start: 0.0, end: None };
t
};
let within = layout_soon(&on(1e9 / 3.0));
assert!(!within.overflow && within.glyphs.len() == 9);
for s in [1e115, 1e9, f64::INFINITY] {
let l = layout_soon(&on(s));
assert!(l.glyphs.is_empty() && l.overflow, "{s}");
}
}
#[test]
fn justify_all_line_widths_equal() {
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 240.0, 800.0), Justify::JustifyAll);