Files
wordcraft/crates/layout/src/ctx.rs
T
alehelandClaude Opus 5.5 56c85834f1 Merge origin/main into the ODT branch (gap 19)
Conflicts kept both sides. Where the two branches met:
- Para.mark: both added it. It stays CharProps (ODT needs it); HTML's
  `<p>&nbsp;</p>` converts its Fmt, and to_doc reads it in one place.
- Arrowheads: the PR's line ends (top left to bottom right) with main's
  arrowhead styles.
- inline_rect: the PR's depth below the baseline with main's rule room.
- Spacing and Cell literals take main's new fields.
- The zip entry guard (#3660) also covers the encrypted ODT paths, which
  parsed a package before it; regression test in tests_more/encryption.rs.
- chunks_exact to as_chunks in odt/frames.rs and odt/path.rs (clippy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:51:35 +01:00

458 lines
20 KiB
Rust

use super::cache::{Key, LayoutCache, Totals, has_page_fields, has_total_fields, hash_of};
use super::floats::{EarlyFloats, WrapArea};
use super::headers::HfLinks;
use super::placed::Placed;
use super::{
Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level,
ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
};
/// A layout pass: finished, or to be done again with the page totals it found, or with the
/// floats placed above their anchors it found ([`EarlyFloats`]).
pub(crate) enum Pass {
Done(DocLayout),
Again(Totals),
Rewrap(EarlyFloats),
}
/// Walk state while paginating.
pub(crate) struct Ctx<'a> {
pub(crate) doc: &'a Document,
pub(crate) cache: &'a mut LayoutCache,
pub(crate) opts: &'a LayoutOptions,
pub(crate) counters: Counters,
pub(crate) fields: FieldCtx,
pub(crate) notes_hash: u64,
pub(crate) numbers: HashMap<u32, Arc<ParaLayout>>,
/// Automatic equation numbers so far.
pub(crate) eq_count: u32,
/// Bounds laying out text boxes inside text boxes, for the whole layout.
pub(crate) boxes: wordcraft_doc::BoxBudget,
/// Linked text boxes (Create Link).
pub(crate) chains: textbox::Chains,
/// The current section's document grid (body text only: none for headers and footers).
pub(crate) grid: para::Grid,
/// Whether tracked deletions are left out in the story being laid out
/// ([`LayoutOptions::hides_deleted`]): the body's, or the story of the [`layout_box`] at work.
pub(crate) hide_deleted: HideDeleted,
/// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`,
/// `SECTIONPAGES`), which is only known once the whole body is paginated.
pub(crate) uses_totals: bool,
/// Whether to note `uses_totals` now (off while laying out headers and footers).
pub(crate) track_totals: bool,
/// The width a top-level table's percentage width is a share of, when it isn't the box's:
/// an inherited header or footer's tables follow the section that defines it.
pub(crate) pct_base: Option<f32>,
/// Empty body paragraphs with hidden marks or continuous section breaks, in order:
/// they take no room (see [`place_para`]).
pub(crate) quiet_marks: Vec<usize>,
/// Empty paragraphs closing a continuous section right after another section's mark, in
/// order: each takes its line while it fits, then stays at the page's bottom (#3907).
pub(crate) stacked_marks: Vec<usize>,
/// Whether even pages are mirrored (mirrored margins, on pages): see [`mirror_even_pages`].
pub(crate) mirror: bool,
/// Cache-key hashes of the table-style text formatting cells were laid out with
/// ([`Ctx::table_key`]).
pub(crate) table_keys: Vec<(para::CellText, u64)>,
/// While a header or footer with tables is laid out: its page, for floats in its cells placed
/// on the page (see [`with_pin`]).
pub(crate) pin: Option<Pin>,
pub(crate) footer_wraps: HashMap<(usize, u32, bool), Vec<WrapArea>>,
pub(crate) header_tables: HashMap<(usize, u32, bool), Vec<Rect>>,
/// Heights of text boxes that resize to fit their text, by (story, width), worked out once
/// per layout ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32), Option<f32>>,
/// Each body float's latest placement: on which page and with what wrap area, when it is
/// above its anchor paragraph with text before it ([`EarlyFloats`]).
pub(crate) floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>,
/// The document's sections with their headers and footers linked ([`HfLinks`]).
pub(crate) hf: Arc<HfLinks<'a>>,
/// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]).
pub(crate) alphas: contour::Alphas,
/// Items re-placed as repeated table header rows so far, in the whole layout (see
/// [`MAX_HEADER_ITEMS`]).
pub(crate) header_repeats: usize,
}
struct ParaEnvInput<'a> {
width: f32,
label: Option<(String, Level)>,
table: Option<&'a para::CellText>,
exclusions: &'a [para::Exclusion],
eq_number: u32,
typing_word: Option<(usize, usize)>,
fitted: &'a [(usize, f32)],
}
/// A header or footer (`story`) laid out for page `number` of a section (`sect`, `mirror`): its
/// table cells' floats placed on the page rather than in their cell ([`Float::outside_cell`],
/// Word's picture watermark in a header that starts with a table), drawn behind the header's
/// text (`behind`) or in front, in page coordinates. Once each (`seen`): a cell may be laid out
/// more than once.
pub(crate) struct Pin {
pub(crate) story: StoryRef,
pub(crate) sect: SectionProps,
/// Rendered header Margin boundary; absent in footers and height probes.
pub(crate) margin_top: Option<f32>,
pub(crate) number: u32,
pub(crate) mirror: bool,
pub(crate) behind: Vec<Placed>,
pub(crate) front: Vec<Placed>,
pub(crate) seen: HashSet<(Vec<u32>, usize)>,
}
/// Whether a float's position on both axes is measured on the page (from its edges or margins),
/// not from its column, paragraph, character or line: one that can be placed on the page from
/// anywhere.
pub(crate) fn page_anchored(f: &Float) -> bool {
use Anchor::*;
matches!(f.h_rel, Page | Margin | LeftMargin | RightMargin | InsideMargin | OutsideMargin)
&& matches!(f.v_rel, Page | Margin | TopMargin | BottomMargin | InsideMargin | OutsideMargin)
}
/// Run `f`, a layout of header or footer `id` for page `number` of `sect`, with the floats in its
/// table cells that are placed on the page put there (see [`Pin`]). Returns `f`'s result and those
/// floats' items, in page coordinates: behind the header's text, and in front.
pub(crate) fn with_pin<T>(
ctx: &mut Ctx,
id: u32,
sect: &SectionProps,
number: u32,
margin_top: Option<f32>,
f: impl FnOnce(&mut Ctx) -> T,
) -> (T, Vec<Placed>, Vec<Placed>) {
let tables = ctx.doc.parts.get(&id).is_some_and(|p| p.blocks.iter().any(|b| matches!(**b, Block::Table(_))));
if !tables {
return (f(ctx), Vec::new(), Vec::new());
}
let pin = Pin {
story: StoryRef::Part(id),
sect: sect.clone(),
margin_top,
number,
mirror: ctx.mirror,
behind: Vec::new(),
front: Vec::new(),
seen: HashSet::new(),
};
let outer = ctx.pin.replace(pin);
let out = f(ctx);
let pin = std::mem::replace(&mut ctx.pin, outer);
let (behind, front) = pin.map(|p| (p.behind, p.front)).unwrap_or_default();
(out, behind, front)
}
/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]), each
/// with a weak reference to those bytes. The cache outlives the documents it is used with, and
/// an address names the same bytes only while their allocation lives: the weak reference keeps
/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address
/// and length and be taken for it.
///
/// Each also holds the pixels its picture took of a layout's decoding budget, which a layout
/// finding the entry charges as if it had decoded the picture ([`contour::Alphas::charge`]).
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>, u64)>;
/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this
/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it.
/// One kept from before is charged to the budget as if decoded now.
pub(crate) fn picture_outline(
outlines: &mut Outlines,
alphas: &mut contour::Alphas,
key: (String, usize, usize, [u32; 4]),
bytes: &Arc<Vec<u8>>,
crop: [f32; 4],
) -> Option<Arc<Vec<(f32, f32)>>> {
if let Some((_, o, cost)) = outlines.get(&key) {
// Kept from an earlier layout, but it still costs this one's budget: the same pictures
// get outlines whatever the cache holds.
alphas.charge(bytes, *cost).ok()?;
return o.clone();
}
let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new);
if outlines.len() >= 256 {
outlines.clear();
}
outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes)));
outline
}
impl Ctx<'_> {
/// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]);
/// pictures' are worked out once per picture and crop.
pub(crate) fn wrap_outline(&mut self, o: &InlineObject) -> Option<Arc<Vec<(f32, f32)>>> {
let (doc, cache, alphas) = (self.doc, &mut *self.cache, &mut self.alphas);
contour::wrap_outline(o, |media, crop| {
let bytes = doc.media.get(media)?;
let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits));
picture_outline(&mut cache.outlines, alphas, key, bytes, crop)
})
}
/// A laid-out line number (cached).
pub(crate) fn number_para(&mut self, n: u32) -> Arc<ParaLayout> {
if let Some(p) = self.numbers.get(&n) {
return p.clone();
}
let mut p = Paragraph::with_text(
&n.to_string(),
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
);
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
let env = para::ParaEnv {
doc: self.doc,
width: 60.0,
label: None,
fields: &self.fields,
show_hidden: false,
field_codes: false,
hide_deleted: Default::default(),
table: None,
proofing: None,
typing_word: None,
numeral: self.opts.numeral,
exclusions: &[],
eq_number: 0,
read_line_spacing: None,
grid: para::Grid::default(),
fit_objects: false,
fitted: &[],
};
let pl = Arc::new(para::layout_para(&p, &env));
self.numbers.insert(n, pl.clone());
pl
}
/// The list label of `p`, advancing the list counters: call it once per paragraph, in
/// document order (only for paragraphs that will show).
/// Whether `p` gets a list label ([`Ctx::next_label`] is `Some`), without counting it.
pub(crate) fn has_label(&self, p: &Paragraph) -> bool {
p.props
.numbering
.or_else(|| self.doc.styles.resolve_para(&p.props).numbering)
.filter(|n| n.num != 0)
.is_some_and(|n| self.doc.numbering.shows_label(n.num, n.level))
}
pub(crate) fn next_label(&mut self, p: &Paragraph) -> Option<(String, Level)> {
let n = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?;
self.counters.next_label(&self.doc.numbering, n.num, n.level)
}
/// Lay out `p` with its already counted `label`, so laying a paragraph out again (around
/// floating objects, or after it moved) doesn't advance the list counters twice.
pub(crate) fn para_labelled(
&mut self,
p: &Paragraph,
width: f32,
table: Option<&para::CellText>,
exclusions: &[para::Exclusion],
label: Option<(String, Level)>,
) -> Arc<ParaLayout> {
let fitted = self.fitted_boxes(p);
let (key, eq_number) = self.para_key(p, width, table, exclusions, label.as_ref(), &fitted);
self.cache.used.insert(key.clone());
if let Some(pl) = self.cache.paras.get(&key) {
self.cache.hits += 1;
return pl.clone();
}
self.cache.misses += 1;
let typing = key.typing;
let pl = Arc::new(para::layout_para(
p,
&self.para_env(ParaEnvInput { width, label, table, exclusions, eq_number, typing_word: typing, fitted: &fitted }),
));
self.cache.paras.insert(key, pl.clone());
pl
}
/// `p` laid out on its own, as the paragraph after a kept one is measured before it is placed
/// (no wrap areas, proofing, typed word or resized text boxes), through the cache when
/// [`Ctx::para_labelled`] would lay it out with those same inputs: it is then not laid out a
/// second time when it is placed. Counts nothing (list labels, equation numbers, page totals).
/// `None` when the inputs differ.
pub(crate) fn para_ahead(&mut self, p: &Paragraph, width: f32, label: Option<(String, Level)>) -> Option<Arc<ParaLayout>> {
let resized = p
.objects
.iter()
.any(|o| matches!(o, InlineObject::Shape { story: Some(_), float, .. } if float.wrap == Wrap::Inline && float.rot == 0.0));
if self.opts.proofing || self.boxes.inside() || resized || self.hide_deleted != self.opts.hides_deleted(StoryRef::Body) {
return None;
}
let kept = (self.eq_count, self.uses_totals);
let (key, eq_number) = self.para_key(p, width, None, &[], label.as_ref(), &[]);
(self.eq_count, self.uses_totals) = kept;
if key.typing.is_some() {
return None;
}
self.cache.used.insert(key.clone());
if let Some(pl) = self.cache.paras.get(&key) {
self.cache.hits += 1;
return Some(pl.clone());
}
self.cache.misses += 1;
let pl = Arc::new(para::layout_para(
p,
&self.para_env(ParaEnvInput { width, label, table: None, exclusions: &[], eq_number, typing_word: None, fitted: &[] }),
));
self.cache.paras.insert(key, pl.clone());
Some(pl)
}
/// What `measure` reads from `p` laid out as [`Ctx::para_labelled`] would, remembering only
/// the result: a table's columns measure every cell once at an unbounded width, and keeping
/// those layouts would double what a long table holds (and walk them all again on each pass).
pub(crate) fn para_measured(
&mut self,
p: &Paragraph,
width: f32,
table: Option<&para::CellText>,
label: Option<(String, Level)>,
measure: impl FnOnce(&ParaLayout) -> (f32, f32),
) -> (f32, f32) {
let fitted = self.fitted_boxes(p);
let (key, eq_number) = self.para_key(p, width, table, &[], label.as_ref(), &fitted);
self.cache.used.insert(key.clone());
if let Some(m) = self.cache.measures.get(&key) {
self.cache.hits += 1;
return *m;
}
self.cache.misses += 1;
let typing = key.typing;
let m = measure(&para::layout_para(
p,
&self.para_env(ParaEnvInput { width, label, table, exclusions: &[], eq_number, typing_word: typing, fitted: &fitted }),
));
self.cache.measures.insert(key, m);
m
}
/// The heights of `p`'s inline text boxes that resize to fit their text, by object index
/// ([`para::ParaEnv::fitted`]).
fn fitted_boxes(&mut self, p: &Paragraph) -> Vec<(usize, f32)> {
let mut out = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Shape { story: Some(id), w, float, .. } = o else { continue };
if float.wrap != Wrap::Inline || float.rot != 0.0 {
continue;
}
if let Some(h) = self.fitted_height(*id, *w) {
out.push((k, h));
}
}
out
}
/// The height of text box `id`, `w` wide, when it resizes to fit its text (once per layout).
fn fitted_height(&mut self, id: u32, w: f32) -> Option<f32> {
let key = (id, w.to_bits());
if let Some(h) = self.fitted.get(&key) {
return *h;
}
let h = textbox::fitted_height(self, id, w);
self.fitted.insert(key, h);
h
}
/// The height floating object `o`, `w` wide and `h` tall, takes: a text box that resizes to
/// fit its text is as tall as its text, anything else its own height.
pub(crate) fn float_height(&mut self, o: &InlineObject, w: f32, h: f32) -> f32 {
match o {
InlineObject::Shape { story: Some(id), .. } => self.fitted_height(*id, w).unwrap_or(h),
_ => h,
}
}
/// The cache key of `p` laid out with these inputs, and its first automatic equation number
/// (advancing the count past its equations).
fn para_key(
&mut self,
p: &Paragraph,
width: f32,
table: Option<&para::CellText>,
exclusions: &[para::Exclusion],
label: Option<&(String, Level)>,
fitted: &[(usize, f32)],
) -> (Key, u32) {
let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None };
if self.track_totals && !self.uses_totals && has_total_fields(p) {
self.uses_totals = true;
}
// Automatic equation numbers count through the document.
let eq_here: u32 = p
.objects
.iter()
.map(|o| match o {
InlineObject::Equation { math, display, .. } => math::auto_numbers(math, *display),
_ => 0,
})
.sum();
let eq_number = self.eq_count;
self.eq_count = self.eq_count.saturating_add(eq_here);
let key = Key {
rev: p.rev,
width: width.to_bits(),
label: label.map(|(t, l)| format!("{t}|{}|{}|{:?}|{:?}|{:?}|{:?}", l.indent, l.hanging, l.suffix, l.align, l.tab, l.chr)),
page_from: if page.is_some() && !self.fields.page_from.is_empty() { hash_of(&self.fields.page_from) } else { 0 },
page,
table: table.map(|t| self.table_key(t)).unwrap_or(0),
notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 },
excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) },
eq: if eq_here > 0 { eq_number } else { 0 },
read_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing.map(f32::to_bits) } else { None },
grid: self.grid.key(),
fit: self.boxes.inside(),
fitted: if fitted.is_empty() { 0 } else { hash_of(&format!("{fitted:?}")) },
hide_deleted: self.hide_deleted,
sref: if self.fields.styleref.is_some() && styleref::has_styleref(p) { self.fields.styleref_key(p) } else { 0 },
typing: self.opts.typing_word.filter(|t| t.rev == p.rev).map(|t| (t.start, t.end)),
};
(key, eq_number)
}
/// The cache-key hash of a cell's table-style text formatting. Hashing it means formatting it
/// as text, which a long table would otherwise do for each of its paragraphs on every pass; a
/// document's tables only give their text a few different formats.
fn table_key(&mut self, t: &para::CellText) -> u64 {
if let Some((_, h)) = self.table_keys.iter().find(|(c, _)| c == t) {
return *h;
}
let h = hash_of(&format!("{t:?}"));
if self.table_keys.len() >= 64 {
self.table_keys.clear();
}
self.table_keys.push((t.clone(), h));
h
}
fn para_env<'b>(&'b self, input: ParaEnvInput<'b>) -> para::ParaEnv<'b> {
let ParaEnvInput { width, label, table, exclusions, eq_number, typing_word, fitted } = input;
para::ParaEnv {
doc: self.doc,
width,
label,
fields: &self.fields,
show_hidden: self.opts.show_hidden,
field_codes: self.opts.field_codes,
hide_deleted: self.hide_deleted,
table,
proofing: self.opts.proofing.then_some(self.opts.proof),
typing_word,
numeral: self.opts.numeral,
exclusions,
eq_number,
read_line_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing } else { None },
grid: self.grid,
fit_objects: self.boxes.inside(),
fitted,
}
}
}
/// Lay out with percentage-width tables sized by `base` (see [`HfLinks::defining_width`]).
pub(crate) fn with_pct_base<T>(ctx: &mut Ctx, base: Option<f32>, f: impl FnOnce(&mut Ctx) -> T) -> T {
let before = std::mem::replace(&mut ctx.pct_base, base);
let out = f(ctx);
ctx.pct_base = before;
out
}