#3692: a linked text-box chain lays its story out at most 16 widths

Audit finding 7: flow() laid the head box's whole story out again at
every distinct box line length in the chain and kept each layout while
splitting, so a chain of many one-line boxes of different widths
multiplied the story's layout work and memory by the chain length.
Past MAX_FLOW_WIDTHS (16) distinct lengths the text stops: the box that
would need another layout and the rest of the chain show nothing, as
overflow past the last box is hidden. Work is now at most 16x the
story. Test: tests_budget::a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-25 21:01:25 -04:00
committed by Dylan M. Taylor
co-authored by Claude Opus 5.5
parent 4963e30eed
commit 8efba59b35
2 changed files with 54 additions and 1 deletions
+43 -1
View File
@@ -2,14 +2,56 @@
//! is bounded by a cap, and real documents stay well under it.
use super::*;
use wordcraft_doc::Block;
use wordcraft_doc::table::{MAX_COLS, Table};
use wordcraft_doc::{Block, PartKind, Pos, TextBody, para_block};
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
/// A `w` × `h` text box showing `text`, inline at the end of body paragraph `para`; its story id.
fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, Default::default()))]);
if let Some(p) = d.parts.get_mut(&id) {
p.body = TextBody::default();
}
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story: Some(id),
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let off = d.para(StoryRef::Body, &Path::top(para)).map_or(0, |p| p.len());
d.insert_object(&Pos::body(para, off), shape, &Default::default()).unwrap();
id
}
#[test]
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
// Forty linked boxes, each a line deep and each a different width, over a long story: every
// width would lay the whole story out again. Past the cap, the chain's later boxes show nothing.
let n = 40;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
for w in ids.windows(2) {
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
}
let l = lay(&d);
let shown =
l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).count();
assert!(shown > 1, "the first boxes still show the story: {shown}");
assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out");
}
#[test]
fn a_huge_grid_after_is_bounded_like_the_docx_readers() {
// A native document can say a row leaves four billion columns empty after its cells.
+11
View File
@@ -208,6 +208,12 @@ struct Cut {
byte: usize,
}
/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole
/// story out again and is kept while the chain is split, so a chain of boxes that each differ in
/// width would multiply its story's layout work and memory; boxes needing another one show no
/// text. Far above a real chain's few box sizes.
pub(crate) const MAX_FLOW_WIDTHS: usize = 16;
/// Split the text of `chain`'s first box over its boxes.
fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
let Some(&head) = chain.first() else { return Vec::new() };
@@ -236,6 +242,11 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
let key = (len.to_bits(), body.vert);
let items = match layouts.iter().find(|(k, _)| *k == key) {
Some((_, items)) => items.clone(),
// Past the cap the text stops here: this box and the rest show none.
None if layouts.len() >= MAX_FLOW_WIDTHS => {
out.push(Vec::new());
continue;
}
None => {
(ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
let items = lay(ctx, head, &blocks, len, body.vert, depth);