Files
wordcraft/crates/layout/src/textbox.rs
T
Martin BallandClaude Sonnet 5.5 bff0d1d925 #3867 follow-up: header repeats count canvas members, one budget per layout; contours and chain widths don't depend on cache history (#4385)
* #3867: review — header repeats, picture outlines and linked-box widths bounded the same way every layout

- Header repeats: a Clip (floating Drawing Canvas) counts its members, depth-bounded, so a 1000-member canvas no longer counts as one item (CodeRabbit thread 4373103686); the budget is per layout (Ctx), not per table.
- Tight-wrap outlines: each cache entry stores its picture's pixel cost; a cached hit is charged to the layout's decode budget like a decode, so cold and warm layouts (and the CLI) wrap alike.
- Linked boxes: widths are budgeted by laid-out work (MAX_FLOW_WORK), not 16 widths, so ordinary stories keep exact widths for every box.
- header_rows.contains -> index compare; layout-parity doc revision.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: review — a picture that fails to decode still costs its pixels when cached; header repeats restored on retry

- contour: Alphas records the pixels each picture charged (costs map), also when the body then fails to decode, so the cache entry's cost is right and warm layouts charge it like cold ones.
- Snap saves and restores ctx.header_repeats so balance_pass retries don't keep their charge.
- Tests: truncated large PNG cold vs warm; canvas header row end to end through place_table; linked-chain bound <= 8; vertical box's layout differs from every other box's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: CodeRabbit — header and linked-box budgets count every clip descendant

placed_cost counts iteratively (explicit stack, stops past the limit) and is shared by flow_work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: docs — progress log, gap 20 note for layout resource caps and linear-time passes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: review — roadmap gives each cap its unit: header items, wrap-cache pixels, linked text box lines and items

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #3867: CodeRabbit — the vertical-box check runs past the work cap too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #3867: review — a linked chain's work counts the story it lays out, not only the lines it shows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #3867: review — header-row cost import follows main's explicit layout imports (#4501)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #3867: review — a test pins the header repeats that column balancing gives back; docs keep main's caps row and both gap status lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:45:19 +00:00

426 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A text box's text (Shape Format › Text): which way it runs (Text Direction), where it sits
//! between the box's edges (Align Text), and linked boxes, whose text continues from one box in
//! the next (Create Link).
//!
//! The text is laid out in the box's own frame (lines `len` long, stacking `across` deep), cut to
//! what fits, aligned, then turned onto the page as a table cell's turned text is. A chain of
//! linked boxes lays out the first box's story and gives each box the next stretch of it: the
//! sizes come from the boxes' shapes, so a box shows the same text wherever it is laid out.
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use wordcraft_doc::props::{TextDirection, TextVert, VAlign};
use wordcraft_doc::{Block, Blocks, Path, StoryRef, TextBody};
use wordcraft_geom::Rect;
use crate::{BoxInput, Ctx, Placed, fit_box, layout_box};
/// Linked text boxes for one layout, worked out when a linked box is first laid out.
#[derive(Default)]
pub(crate) struct Chains {
built: bool,
/// Box → (chain, place in it).
member: HashMap<u32, (usize, usize)>,
chains: Vec<Vec<u32>>,
/// Each chain's text split over its boxes: per box, items in its unturned frame.
flows: HashMap<usize, Vec<Vec<Placed>>>,
}
impl Chains {
fn build(&mut self, doc: &wordcraft_doc::Document) {
if self.built {
return;
}
self.built = true;
if !doc.parts.values().any(|p| p.body.next.is_some()) {
return;
}
self.chains = doc.text_box_chains();
for (ci, c) in self.chains.iter().enumerate() {
for (k, id) in c.iter().enumerate() {
self.member.insert(*id, (ci, k));
}
}
}
}
/// The story text box `id` shows: the first box's, for a box linked from another.
pub(crate) fn shown_story(ctx: &mut Ctx, id: u32) -> u32 {
ctx.chains.build(ctx.doc);
match ctx.chains.member.get(&id) {
Some((ci, _)) => ctx.chains.chains.get(*ci).and_then(|c| c.first()).copied().unwrap_or(id),
None => id,
}
}
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs as `body`
/// says, inside its internal margins.
fn frame(body: &TextBody, w: f32, h: f32) -> (f32, f32) {
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
let [l, t, r, b] = body.insets_pt();
let (along, across) = match body.vert.turn() {
TextDirection::Horizontal => (w - l - r, h - t - b),
_ => (h - t - b, w - l - r),
};
(along.max(12.0), across)
}
/// The width stacked text is laid out in (a paragraph's narrowest).
const STACK_WIDTH: f32 = 12.0;
/// Lay `blocks` of story `id` out in lines `len` long; stacked text one letter to a line.
fn lay(ctx: &mut Ctx, id: u32, blocks: &Blocks, len: f32, vert: TextVert, depth: usize) -> Vec<Placed> {
let story = StoryRef::Part(id);
if vert == TextVert::Stacked {
// One letter to a line: words may break anywhere, and each letter takes the narrowest
// line a paragraph gets (the spacing after it doesn't show).
let stacked: Blocks = blocks
.iter()
.map(|b| match &**b {
Block::Para(p) => {
let mut p = p.clone();
p.props.word_wrap = Some(false);
for r in &mut p.runs {
r.props.spacing = Some(STACK_WIDTH);
}
p.touch();
Arc::new(Block::Para(p))
}
_ => b.clone(),
})
.collect();
return layout_box(ctx, BoxInput::other(story, &stacked, STACK_WIDTH, depth + 1, None)).items;
}
layout_box(ctx, BoxInput::other(story, blocks, len, depth + 1, None)).items
}
/// The items showing text box `id` in its frame `rect` (page coordinates): its text, or its
/// share of a linked chain's, cut to what fits, aligned and turned as the box says.
pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec<Placed> {
let body = ctx.doc.parts.get(&id).map(|p| p.body).unwrap_or_default();
let (len, across) = frame(&body, rect.w, rect.h);
let items = match chain_share(ctx, id, depth) {
Some(items) => items,
None => {
if !ctx.boxes.enter(id) {
return Vec::new();
}
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
let inner = lay(ctx, id, &blocks, len, body.vert, depth);
ctx.boxes.leave();
// Text that doesn't fit inside the margins is hidden, as in Word; a box that resizes
// to fit its text shows it all (#743).
if body.auto_fit { inner } else { fit_box(inner, across) }
}
};
place(items, &body, rect, across)
}
/// The height of a `w` wide text box `id` that resizes to fit its text (`a:spAutoFit`): its text's
/// depth plus its top and bottom margins, as Word fits it when laying the document out. `None`
/// for a box that keeps its own height: one that doesn't resize, with turned text, or linked.
pub(crate) fn fitted_height(ctx: &mut Ctx, id: u32, w: f32) -> Option<f32> {
let body = ctx.doc.parts.get(&id).map(|p| p.body)?;
if !body.auto_fit || body.vert.turn() != TextDirection::Horizontal || body.next.is_some() {
return None;
}
ctx.chains.build(ctx.doc);
if ctx.chains.member.contains_key(&id) || !ctx.boxes.enter(id) {
return None;
}
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
let (len, _) = frame(&body, w, 0.0);
// Measuring doesn't count the box's list numbers and equations: `box_items` lays it out again.
let snap = (ctx.counters.clone(), ctx.eq_count);
let items = lay(ctx, id, &blocks, len, body.vert, 0);
(ctx.counters, ctx.eq_count) = snap;
ctx.boxes.leave();
let [_, t, _, b] = body.insets_pt();
let h = extent(&items) + t + b;
h.is_finite().then_some(h)
}
/// Move items laid out in a box's frame onto the page: aligned in the `across` room (top,
/// middle, bottom; for turned text, from the side its lines start on) and turned.
fn place(mut items: Vec<Placed>, body: &TextBody, rect: Rect, across: f32) -> Vec<Placed> {
let used = extent(&items);
let off = match body.anchor {
VAlign::Top => 0.0,
VAlign::Center => ((across - used) / 2.0).max(0.0),
VAlign::Bottom => (across - used).max(0.0),
};
let turn = body.vert.turn();
let [l, t, r, b] = body.insets_pt();
let (ox, oy) = match turn {
TextDirection::Horizontal => (rect.x + l, rect.y + t + off),
// Top-to-bottom lines stack from the right edge, bottom-to-top ones from the left.
TextDirection::Down => (rect.right() - r - off, rect.y + t),
TextDirection::Up => (rect.x + l + off, rect.bottom() - b),
};
for it in &mut items {
it.turn(turn, ox, oy);
}
items
}
/// How deep laid-out items reach below their frame's top.
fn extent(items: &[Placed]) -> f32 {
let mut bottom = 0.0f32;
for it in items {
let b = match it {
_ if it.turned_bounds().is_some() => it.turned_bounds().map_or(0.0, |r| r.bottom()),
Placed::Lines { para, l0, l1, y, .. } => {
let (Some(first), Some(last)) = (para.lines.get(*l0), l1.checked_sub(1).and_then(|k| para.lines.get(k))) else { continue };
y + last.top + last.height - first.top
}
Placed::Rule { y0, y1, .. } => y0.max(*y1),
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Clip { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. } => rect.bottom(),
};
if b.is_finite() {
bottom = bottom.max(b);
}
}
bottom
}
/// Box `id`'s share of its chain's text (items in its unturned frame), if it is linked.
fn chain_share(ctx: &mut Ctx, id: u32, depth: usize) -> Option<Vec<Placed>> {
ctx.chains.build(ctx.doc);
let (ci, k) = *ctx.chains.member.get(&id)?;
if !ctx.chains.flows.contains_key(&ci) {
let chain = ctx.chains.chains.get(ci).cloned().unwrap_or_default();
let flow = flow(ctx, &chain, depth);
ctx.chains.flows.insert(ci, flow);
}
Some(ctx.chains.flows.get(&ci).and_then(|f| f.get(k)).cloned().unwrap_or_default())
}
/// Where a box's text starts: the line holding byte `byte` of paragraph `path`.
struct Cut {
path: Path,
byte: usize,
}
/// Most work a chain's story is laid out for, summed over its layouts. Each distinct line length
/// (and text direction) lays the whole story out again and is kept while the chain is split, so
/// a chain of boxes that each differ in width multiplies its story's layout work and memory by
/// its widths. A layout costs the lines and other items it gives, or its story's text at
/// [`STORY_BYTES_PER_WORK`] bytes a unit, whichever is more: text that gives few lines (hidden,
/// deleted, very wide lines, or cut by a box inside) still costs what shaping it does. So the
/// layouts of a chain shape at most about 64 MB of text, plus the one that crosses the cap and
/// the first in each other direction. Within this every box of an ordinary story (even all
/// [`wordcraft_doc::MAX_LINKED_BOXES`]) keeps its exact width; past it, a box whose length isn't
/// among those laid out takes the nearest narrower one laid out in its text direction, so its
/// lines still fit across it (they stop short of its far edge), else the narrowest, whose lines
/// are longer than the box. Only a huge story gets there.
pub(crate) const MAX_FLOW_WORK: usize = 1_000_000;
/// Bytes of story text that cost one unit of [`MAX_FLOW_WORK`]: about a line's worth.
pub(crate) const STORY_BYTES_PER_WORK: usize = 64;
#[cfg(test)]
thread_local! {
/// A smaller cap for tests, so a short story can reach it.
pub(crate) static FLOW_WORK_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_FLOW_WORK) };
/// How many times chains' stories have been laid out, for tests.
pub(crate) static FLOW_LAYOUTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn flow_work_cap() -> usize {
#[cfg(test)]
return FLOW_WORK_CAP.with(|c| c.get());
#[cfg(not(test))]
MAX_FLOW_WORK
}
/// What laying `items` out cost: its lines and other items (a clipped group's members, nested
/// ones too, each count, its lines each).
pub(crate) fn flow_work(items: &[Placed]) -> usize {
let cap = flow_work_cap();
items.iter().map(|it| super::placed_cost_by(it, cap, line_count)).fold(0, usize::saturating_add)
}
/// One for an item, or its lines for a run of lines.
fn line_count(it: &Placed) -> usize {
match it {
Placed::Lines { l0, l1, .. } => l1.saturating_sub(*l0).max(1),
_ => 1,
}
}
/// The work laying `blocks` out costs whatever it gives (see [`MAX_FLOW_WORK`]): its text's
/// bytes, tables' cells too, over [`STORY_BYTES_PER_WORK`].
fn story_work(blocks: &Blocks) -> usize {
let mut bytes = 0usize;
let mut stack: Vec<&Arc<Block>> = blocks.iter().collect();
while let Some(b) = stack.pop() {
match &**b {
Block::Para(p) => bytes = bytes.saturating_add(p.text.len()),
Block::Table(t) => stack.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks)),
}
}
bytes / STORY_BYTES_PER_WORK
}
/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart
/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size.
fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) {
let len = if vert == TextVert::Stacked { STACK_WIDTH } else { len };
((len * 20.0).round() as i32, vert)
}
/// 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() };
if !ctx.boxes.enter(head) {
return vec![Vec::new(); chain.len()];
}
let ids: BTreeSet<u32> = chain.iter().copied().collect();
let sizes = ctx.doc.text_box_sizes(&ids);
let blocks = ctx.doc.parts.get(&head).map(|p| p.blocks.clone()).unwrap_or_default();
// Each box lays the whole story out at its own line length (the same list numbers every
// time), sharing a layout with the boxes before it of the same length.
let snap = (ctx.counters.clone(), ctx.eq_count);
let mut after = None;
let mut layouts: Vec<((i32, TextVert), Vec<Placed>)> = Vec::new();
// Work done by the layouts so far (see `MAX_FLOW_WORK`).
let mut work = 0usize;
let floor = story_work(&blocks);
let mut out = Vec::with_capacity(chain.len());
// Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown.
let mut start: Option<Option<Cut>> = Some(None);
for id in chain {
let Some(cut) = start.take() else {
out.push(Vec::new());
continue;
};
let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default();
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
let (len, across) = frame(&body, w, h);
let key = flow_key(len, body.vert);
// Past the work cap the box takes the nearest layout in its direction: the widest line length
// that fits it, else the narrowest. The first box in a direction is laid out (one more
// layout for each of the few directions at most).
let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0)));
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { near() } else { None });
let items = match kept {
Some((_, items)) => items.clone(),
None => {
(ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
let items = lay(ctx, head, &blocks, len, body.vert, depth);
after.get_or_insert_with(|| (ctx.counters.clone(), ctx.eq_count));
#[cfg(test)]
FLOW_LAYOUTS.with(|c| c.set(c.get().saturating_add(1)));
work = work.saturating_add(flow_work(&items).max(floor));
layouts.push((key, items.clone()));
items
}
};
let items = match &cut {
None => items,
Some(cut) => match cut_y(&items, cut) {
Some(top) => from_y(items, top),
None => {
out.push(Vec::new());
continue;
}
},
};
let (kept, next) = fit_flow(items, across);
start = next.map(Some);
out.push(kept);
}
if let Some(a) = after {
(ctx.counters, ctx.eq_count) = a;
}
ctx.boxes.leave();
out
}
/// Where `cut` is in a layout: the top of the line holding its byte.
fn cut_y(items: &[Placed], cut: &Cut) -> Option<f32> {
items.iter().find_map(|it| match it {
Placed::Lines { path, para, l0, l1, y, .. } if *path == cut.path => {
let first = para.lines.get(*l0)?;
let k = (*l0..*l1).find(|k| para.lines.get(*k).is_some_and(|l| l.stop > cut.byte)).unwrap_or(l1.saturating_sub(1).max(*l0));
para.lines.get(k).map(|l| y + l.top - first.top)
}
_ => None,
})
}
/// The items from `top` down, moved up to start at 0.
fn from_y(items: Vec<Placed>, top: f32) -> Vec<Placed> {
let lim = top - 0.5;
let mut out = Vec::with_capacity(items.len());
for it in items {
if let Some(r) = it.turned_bounds() {
if r.y >= lim {
out.push(it);
}
continue;
}
match it {
Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
let Some(first) = para.lines.get(l0) else { continue };
let Some(k) = (l0..l1).find(|k| para.lines.get(*k).is_some_and(|l| y + l.top - first.top >= lim)) else { continue };
let ky = para.lines.get(k).map_or(y, |l| y + l.top - first.top);
out.push(Placed::Lines { story, path, para, l0: k, l1, x, y: ky, turn });
}
Placed::Fill { rect, color } if rect.bottom() > lim => {
let y0 = rect.y.max(top);
out.push(Placed::Fill { rect: Rect::new(rect.x, y0, rect.w, rect.bottom() - y0), color });
}
Placed::Rule { y0, y1, .. } if y0.max(y1) >= lim => out.push(it),
Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Clip { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. }
if rect.y >= lim =>
{
out.push(it)
}
_ => {}
}
}
for it in &mut out {
it.translate(0.0, -top);
}
out
}
/// What fits in `across` (see [`fit_box`]), and where the text left over starts.
fn fit_flow(items: Vec<Placed>, across: f32) -> (Vec<Placed>, Option<Cut>) {
let limit = across + 0.5;
let mut kept_line = false;
let mut cut: Option<(f32, Cut)> = None;
for it in &items {
if it.turned_bounds().is_some() {
continue;
}
let Placed::Lines { path, para, l0, l1, y, .. } = it else { continue };
let Some(first) = para.lines.get(*l0) else { continue };
for k in *l0..*l1 {
let Some(l) = para.lines.get(k) else { break };
let ly = y + l.top - first.top;
if ly + l.height > limit && kept_line {
if cut.as_ref().is_none_or(|(cy, _)| ly < *cy) {
cut = Some((ly, Cut { path: path.clone(), byte: l.start }));
}
break;
}
kept_line = true;
}
}
(fit_box(items, across), cut.map(|(_, c)| c))
}