//! WordCraft layout: turns a [`Document`] into pages. //! //! - [`para`]: one paragraph → lines (shaping, first-fit line breaking, tabs, list labels, //! alignment). Results are memoised per paragraph revision in a [`LayoutCache`]. //! - [`layout`]: sections → pages and columns; tables; keep/widow rules; page breaks; headers //! and footers; page-number fields. //! - [`display`]: a page → draw items (glyph runs, rules, fills, images) for the renderers. //! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod balloons; mod contour; pub mod display; pub mod fields; pub mod hit; pub mod kinsoku; pub mod math; pub mod outline; pub mod para; mod table; mod textbox; mod vertical; pub mod wordart; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::Arc; use wordcraft_doc::numbering::{Counters, Level}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap, WrapText}; use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat, TextDirection}; use wordcraft_doc::section::{HeaderSet, SectionProps, SectionStart}; use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef}; use wordcraft_geom::{Point, Rect, Spin}; pub use fields::FieldCtx; pub use hit::VisualStep; pub use para::{LineEnd, ParaLayout}; pub use table::{autofit_widths, measure_table_columns}; /// How the document is viewed. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub enum ViewMode { #[default] Print, /// One page as wide as the window, no page breaks. Web, /// Draft: like web, page breaks shown as rules. Draft, /// Outline: like draft, the body as an outline of its headings ([`outline`]). Outline, } #[derive(Clone, Debug, Default)] pub struct LayoutOptions { pub view: ViewMode, /// Width of the window in Web/Draft views, points. pub web_width: f32, pub show_hidden: bool, /// Final text ("No Markup"): tracked deletions take no space and draw nothing. pub hide_deleted: bool, /// Show revisions in balloons: tracked deletions in the body take no space and draw nothing /// (the editor shows them in balloons beside the page). Headers, footers, notes and text /// boxes get no balloons, so their deletions stay inline. pub deletions_in_balloons: bool, /// Check spelling as you type (squiggles). pub proofing: bool, /// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`] /// and show the comments there in balloons joined to their anchors, as Word prints them. /// The editor draws its own interactive balloons and leaves this off. pub comment_balloons: bool, /// Show field codes (`{ PAGE }`, shaded) instead of field results (View › Field Codes, Alt+F9). /// A field whose own `code` flag is set shows the other one. pub field_codes: bool, /// Outline view: what's collapsed and shown (used when `view` is [`ViewMode::Outline`]). pub outline: Option, /// What the spelling check skips, and whether grammar is marked too (File › Options › Proofing). pub proof: ProofOptions, } /// File › Options › Proofing choices that change the squiggles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ProofOptions { /// Mark grammar errors as you type. pub grammar: bool, pub spelling: wordcraft_proof::Options, } impl Default for ProofOptions { fn default() -> Self { ProofOptions { grammar: true, spelling: wordcraft_proof::Options::default() } } } impl LayoutOptions { /// Whether tracked deletions in `story` take no space and draw nothing. pub fn hides_deleted(&self, story: StoryRef) -> bool { self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body) } } /// Something placed on a page (page coordinates, points, y down). #[derive(Clone, Debug)] pub enum Placed { /// Lines `l0..l1` of a paragraph; `y` is the top of line `l0`; `x` the column's left edge. /// Turned lines (a table cell's text direction) run as `turn` says from the page point /// (`x`, `y`): see [`turn_point`]. Lines { story: StoryRef, path: Path, para: Arc, l0: usize, l1: usize, x: f32, y: f32, turn: TextDirection, }, Fill { rect: Rect, color: Rgb, }, Rule { x0: f32, y0: f32, x1: f32, y1: f32, border: Border, }, /// A picture in `rect`, turned about the rect's centre by `spin` (as are shapes and charts). Image { rect: Rect, media: String, crop: [f32; 4], story: StoryRef, path: Path, off: usize, spin: Spin, }, Shape { rect: Rect, kind: wordcraft_doc::para::ShapeKind, fill: Option, stroke: Option, stroke_width: f32, /// A freeform's paths (ink strokes among them). freeform: Option>, effects: wordcraft_doc::effects::ShapeEffects, spin: Spin, /// Arrowheads and text warp (see [`wordcraft_doc::connector::ShapeExtra`]). extra: wordcraft_doc::connector::ShapeExtra, }, /// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at /// byte `off` of paragraph `path` (its alt text). Graphic { rect: Rect, graphic: Arc, story: StoryRef, path: Path, off: usize, spin: Spin, }, /// `items` clipped to `rect`: a floating Drawing Canvas's members, kept inside its frame. /// `rect` is the unturned frame and `items` sit in it; `spin` turns the clipped whole about /// the frame's centre. Clip { rect: Rect, spin: Spin, items: Vec, }, /// A table cell's area (for hit testing and cell selection). A vertically merged cell is /// one `Cell` as tall as all its rows; its continuation rows get a `covered` `Cell` (their /// own row's height) so the rows keep a box each, but it isn't a cell of its own on screen. Cell { rect: Rect, covered: bool, table: Path, row: usize, cell: usize, story: StoryRef, }, /// The area of a picture, shape or text box, for hit testing, selection handles and dragging /// (drawn by `Image`/`Shape`/`Lines`). The object is the U+FFFC at byte `off` of paragraph /// `path` in `story`. `rect` is its unrotated frame; `spin` turns it about the centre. Object { rect: Rect, spin: Spin, story: StoryRef, path: Path, off: usize, /// The text box story it shows (`Document::parts` id). text_box: Option, wrap: Wrap, /// Its paragraph's column left and top: where offsets relative to the column and the /// paragraph (`Anchor::Column` / `Anchor::Paragraph`) start. origin: wordcraft_geom::Point, }, } impl Placed { fn translate(&mut self, dx: f32, dy: f32) { match self { Placed::Lines { x, y, .. } => { *x += dx; *y += dy; } Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Cell { rect, .. } => { rect.x += dx; rect.y += dy; } Placed::Object { rect, origin, .. } => { rect.x += dx; rect.y += dy; origin.x += dx; origin.y += dy; } Placed::Clip { rect, items, .. } => { rect.x += dx; rect.y += dy; for it in items { it.translate(dx, dy); } } Placed::Rule { x0, y0, x1, y1, .. } => { *x0 += dx; *x1 += dx; *y0 += dy; *y1 += dy; } } } /// The page area of turned lines (`None` for anything else). pub fn turned_bounds(&self) -> Option { let Placed::Lines { para, l0, l1, x, y, turn, .. } = self else { return None }; if !turn.is_turned() { return None; } let first = para.lines.get(*l0)?; let last = para.lines.get(l1.checked_sub(1)?)?; let len = para.lines.get(*l0..*l1).unwrap_or(&[]).iter().map(|l| l.right).fold(0.0f32, f32::max); Some(turn_rect(*turn, *x, *y, Rect::new(0.0, 0.0, len, last.top + last.height - first.top))) } /// Move an item laid out in a turned frame (a table cell's text running `turn`) onto the /// page, the frame's origin landing on page point (`x`, `y`). fn turn(&mut self, turn: TextDirection, x: f32, y: f32) { if !turn.is_turned() { self.translate(x, y); return; } match self { Placed::Lines { x: lx, y: ly, turn: t, .. } => { (*lx, *ly) = turn_point(turn, x, y, *lx, *ly); // Turned text inside turned text keeps its own direction (no 180° text). if !t.is_turned() { *t = turn; } } Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Cell { rect, .. } => { *rect = turn_rect(turn, x, y, *rect); } Placed::Object { rect, origin, .. } => { *rect = turn_rect(turn, x, y, *rect); (origin.x, origin.y) = turn_point(turn, x, y, origin.x, origin.y); } Placed::Clip { rect, items, .. } => { *rect = turn_rect(turn, x, y, *rect); for it in items { it.turn(turn, x, y); } } Placed::Rule { x0, y0, x1, y1, .. } => { (*x0, *y0) = turn_point(turn, x, y, *x0, *y0); (*x1, *y1) = turn_point(turn, x, y, *x1, *y1); } } } } /// Where point (`u`, `v`) of a frame turned `turn` lands on the page, the frame's origin being /// page point (`x`, `y`): `u` runs along the lines, `v` across them (down for horizontal text). pub fn turn_point(turn: TextDirection, x: f32, y: f32, u: f32, v: f32) -> (f32, f32) { match turn { TextDirection::Horizontal => (x + u, y + v), TextDirection::Down => (x - v, y + u), TextDirection::Up => (x + v, y - u), } } /// The inverse of [`turn_point`]: the frame point at page point (`px`, `py`). pub fn unturn_point(turn: TextDirection, x: f32, y: f32, px: f32, py: f32) -> (f32, f32) { match turn { TextDirection::Horizontal => (px - x, py - y), TextDirection::Down => (py - y, x - px), TextDirection::Up => (y - py, px - x), } } /// The page rectangle a frame rectangle covers (see [`turn_point`]). pub fn turn_rect(turn: TextDirection, x: f32, y: f32, r: Rect) -> Rect { match turn { TextDirection::Horizontal => Rect::new(x + r.x, y + r.y, r.w, r.h), TextDirection::Down => Rect::new(x - r.y - r.h, y + r.x, r.h, r.w), TextDirection::Up => Rect::new(x + r.y, y - r.x - r.w, r.h, r.w), } } /// One laid-out page. #[derive(Clone, Debug, Default)] pub struct Page { pub w: f32, pub h: f32, /// Index into `Document::sections()`. pub section: usize, /// Page number as displayed (after section restarts). pub number: u32, pub items: Vec, /// Header / footer items (drawn dimmed while editing the body). pub header: Vec, pub footer: Vec, /// The body text area (margins). pub body: Rect, pub header_story: Option, pub footer_story: Option, /// Top-level body block index at the start of the page. pub first_block: usize, /// Width of the markup area on the right of `w` that holds comment balloons (Print Markup, /// [`LayoutOptions::comment_balloons`]); 0 without one. The document page is `w - markup` wide. pub markup: f32, } /// The whole layout. #[derive(Clone, Debug, Default)] pub struct DocLayout { pub pages: Vec, /// Index: (story, paragraph path) → (page, item index) of each placed piece, in order. pub index: HashMap<(StoryRef, Path), Vec<(usize, usize)>>, /// Layout time, milliseconds. pub ms: f64, /// Text box stories laid out (each time a box's text was). pub text_boxes: usize, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct Key { rev: u64, width: u32, label: Option, page: Option<(u32, u32, u32, u32)>, table: u64, notes: u64, excl: u64, eq: u32, grid: (u32, u32, u32, bool), hide_deleted: bool, } /// Memoised paragraph layouts. #[derive(Default)] pub struct LayoutCache { paras: HashMap>, /// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the /// media's bytes (address and length) and crop. outlines: HashMap<(String, usize, usize, [u32; 4]), Option>>>, env: u64, used: HashSet, pub hits: u64, pub misses: u64, } impl LayoutCache { pub fn new() -> Self { Self::default() } pub fn len(&self) -> usize { self.paras.len() } pub fn is_empty(&self) -> bool { self.paras.is_empty() } pub fn clear(&mut self) { self.paras.clear(); self.outlines.clear(); } } fn hash_of(t: &T) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); t.hash(&mut h); h.finish() } fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default(); // Outline view lays paragraphs out changed (no indents; plain without text formatting). let outline = (opts.view == ViewMode::Outline).then(|| opts.outline.as_ref().is_some_and(|o| o.plain)); hash_of(&( s, opts.show_hidden, opts.hide_deleted, opts.deletions_in_balloons, opts.proofing, opts.field_codes, opts.proof, wordcraft_proof::dictionary_generation(), hyphenation_key(doc), outline, )) } /// The hyphenation settings, hashable (the zone as its bits). fn hyphenation_key(doc: &Document) -> (bool, u32, bool, u32) { let s = &doc.settings; (s.auto_hyphenation, s.hyphenation_zone.to_bits(), s.hyphenate_caps, s.consecutive_hyphen_limit) } fn has_page_fields(p: &Paragraph) -> bool { p.objects.iter().any(|o| match o { InlineObject::Field { instr, .. } => { matches!(fields::field_name(instr).as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION") } _ => false, }) } /// Does `p` show a page total, which depends on how many pages the document has? fn has_total_fields(p: &Paragraph) -> bool { p.objects.iter().any(|o| match o { InlineObject::Field { instr, .. } => matches!(fields::field_name(instr).as_str(), "NUMPAGES" | "SECTIONPAGES"), _ => false, }) } /// Page totals: of the document, and of each section (by index). #[derive(Clone, Debug, PartialEq)] struct Totals { pages: u32, sections: Vec, } impl Totals { /// What a first pass assumes: one page. fn first_guess(sections: usize) -> Totals { Totals { pages: 1, sections: vec![1; sections] } } /// The totals of laid-out `pages` (a section without a page of its own counts one, as in /// headers and footers). fn of(pages: &[Page], sections: usize) -> Totals { let mut per = vec![0u32; sections]; for p in pages { if let Some(n) = per.get_mut(p.section) { *n = n.saturating_add(1); } } for n in &mut per { *n = (*n).max(1); } Totals { pages: u32::try_from(pages.len()).unwrap_or(u32::MAX).max(1), sections: per } } fn section(&self, si: usize) -> u32 { self.sections.get(si).copied().unwrap_or(1) } } /// The most extra layout passes for page totals in the body: a total that changes the page /// count (its digits pushing text to another page) is tried again, this many times at most. const MAX_TOTAL_PASSES: usize = 3; /// A layout pass: finished, or to be done again with the page totals it found. enum Pass { Done(DocLayout), Again(Totals), } /// Walk state while paginating. struct Ctx<'a> { doc: &'a Document, cache: &'a mut LayoutCache, opts: &'a LayoutOptions, counters: Counters, fields: FieldCtx, notes_hash: u64, numbers: HashMap>, /// Automatic equation numbers so far. eq_count: u32, /// Bounds laying out text boxes inside text boxes, for the whole layout. boxes: wordcraft_doc::BoxBudget, /// Linked text boxes (Create Link). chains: textbox::Chains, /// The current section's document grid (body text only: none for headers and footers). 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. hide_deleted: bool, /// 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. uses_totals: bool, /// Whether to note `uses_totals` now (off while laying out headers and footers). track_totals: bool, } impl Ctx<'_> { /// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]); /// pictures' are worked out once per picture and crop. fn wrap_outline(&mut self, o: &InlineObject) -> Option>> { let (doc, cache) = (self.doc, &mut *self.cache); 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)); if let Some(o) = cache.outlines.get(&key) { return o.clone(); } if cache.outlines.len() >= 256 { cache.outlines.clear(); } let outline = contour::picture_outline(bytes, crop).map(Arc::new); cache.outlines.insert(key, outline.clone()); outline }) } /// A laid-out line number (cached). fn number_para(&mut self, n: u32) -> Arc { 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: false, table: None, proofing: None, exclusions: &[], eq_number: 0, grid: para::Grid::default(), }; 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). 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. fn para_labelled( &mut self, p: &Paragraph, width: f32, table: Option<¶::CellText>, exclusions: &[para::Exclusion], label: Option<(String, Level)>, ) -> Arc { 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.as_ref().map(|(t, l)| format!("{t}|{}|{}|{:?}|{:?}|{:?}|{:?}", l.indent, l.hanging, l.suffix, l.align, l.tab, l.chr)), page, table: table.map(|t| hash_of(&format!("{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 }, grid: self.grid.key(), hide_deleted: self.hide_deleted, }; 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 env = 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), exclusions, eq_number, grid: self.grid, }; let pl = Arc::new(para::layout_para(p, &env)); self.cache.paras.insert(key, pl.clone()); pl } } /// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden /// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the /// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is /// laid out with (`StyleSheet::resolve_char_in`). fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool { let mut left = Vec::new(); // Nothing can be left out with hidden text shown and markup on. if (hide_deleted || !show_hidden) && !p.objects.is_empty() { let style = p.props.style.as_deref(); let is_left = |c: &CharProps| { let rc = doc.styles.resolve_char_in(style, table_chr, c); para::left_out(&rc, show_hidden, hide_deleted) }; // Runs and objects are both in order: one walk finds each object's run (past the last run, // the paragraph mark, as `props_of_char` gives), resolving a run's formatting only once. let mut runs = p.run_ranges().peekable(); let mut last: Option<(usize, bool)> = None; for off in p.object_offsets() { while runs.next_if(|(r, _)| r.end <= off).is_some() {} let l = match runs.peek() { Some((r, c)) => match last { Some((end, l)) if end == r.end => l, _ => { let l = is_left(c); last = Some((r.end, l)); l } }, None => is_left(&p.mark), }; left.push(l); } } move |k| left.get(k).copied().unwrap_or(false) } /// Footnote and endnote numbers (each kind counted separately), in document order, as each /// section's options say: starting at its "start at", numbered on, or starting again in each /// section or (footnotes) on each page. `pages` gives the page each footnote was placed on by an /// earlier pass (empty: numbered on). With `hide_deleted`, notes whose reference mark is a /// tracked deletion are left out, as in the final text. fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap) -> HashMap { use wordcraft_doc::section::NoteRestart; let sections = doc.sections(); let opts: Vec<[wordcraft_doc::section::NoteOptions; 2]> = sections.iter().map(|(_, s)| [doc.note_options(s, false), doc.note_options(s, true)]).collect(); let mut m = HashMap::new(); // Per kind (footnotes, endnotes): the last number, and the section and page of the last note. let mut last: [Option<(u32, usize, Option)>; 2] = [None, None]; let mut order = [0u32; 2]; let mut si = 0usize; // Reading order, text boxes included where they are. A hidden reference mark still takes // its number, as in Word; a deleted one (or one in a deleted text box) does not. let mut cur: Option<(usize, Vec)> = None; doc.objects_in_reading_order_by_block(&mut |block, p, k| { let key = p as *const Paragraph as usize; if cur.as_ref().is_none_or(|(at, _)| *at != key) { let deleted = left_out_objects(doc, p, None, true, hide_deleted); cur = Some((key, (0..p.objects.len()).map(deleted).collect())); } if cur.as_ref().is_some_and(|(_, deleted)| deleted.get(k).copied().unwrap_or(false)) { return false; } if let Some(InlineObject::NoteRef { kind, id, .. }) = p.objects.get(k) { while sections.get(si).is_some_and(|(end, _)| block > *end) && si + 1 < sections.len() { si += 1; } let ki = usize::from(*kind == wordcraft_doc::para::NoteKind::Endnote); let Some(o) = opts.get(si).and_then(|o| o.get(ki)) else { return true }; let page = if ki == 0 { pages.get(id).copied() } else { None }; let Some(slot) = last.get_mut(ki) else { return true }; let n = match *slot { None => o.num_start, Some((prev, psect, ppage)) => { let again = match o.num_restart { NoteRestart::Continuous => false, NoteRestart::EachSect => psect != si, NoteRestart::EachPage => page.is_some() && page != ppage, }; if again { o.num_start } else { prev.saturating_add(1) } } }; *slot = Some((n, si, page)); let ord = order.get_mut(ki).map(|c| { *c = c.saturating_add(1); *c }); m.insert(*id, fields::NoteNum { n, fmt: o.num_fmt, section: si, order: ord.unwrap_or(0) }); } true }); m } /// Note part ids → numbers, numbered on (see [`note_info`]). #[cfg(test)] fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect() } /// The page each footnote's text starts on. fn footnote_pages(doc: &Document, pages: &[Page]) -> HashMap { let mut m = HashMap::new(); for (pi, p) in pages.iter().enumerate() { for it in &p.items { if let Placed::Lines { story: StoryRef::Part(id), .. } | Placed::Cell { story: StoryRef::Part(id), .. } = it && doc.parts.get(id).is_some_and(|part| part.kind == wordcraft_doc::PartKind::Footnote) { m.entry(*id).or_insert(pi); } } } m } /// Lay out a block list into a free-standing box of `width` (no page breaks): table cells, /// headers, footers, notes, text boxes. Returns items relative to (0, 0) and the height. `frame` /// says where the box sits on its page, for floating objects positioned relative to the page or /// its margins; without it (cells, notes, text boxes) they're positioned in the box. #[allow(clippy::too_many_arguments)] fn layout_box( ctx: &mut Ctx, story: StoryRef, blocks: &Blocks, prefix: &[u32], width: f32, table: Option<¶::CellText>, depth: usize, frame: Option, ) -> BoxLayout { // Deletions in balloons leave the body's text only: a header, note or text box keeps its own // inline (restored for the story around this box when it's done). let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story)); let mut items = Vec::new(); // Pictures behind the text go first; `behind` counts them. let mut behind = 0; // Wrap areas of the box's floating objects. let mut excl: Vec = Vec::new(); // Areas of the box's floating tables, which other floating tables may have to avoid. let mut float_tables: Vec = Vec::new(); let mut y = 0.0f32; let mut prev_after = 0.0f32; let mut prev_style: Option<(String, bool)> = None; for (i, b) in blocks.iter().enumerate() { let mut path = prefix.to_vec(); path.push(i as u32); match &**b { Block::Para(p) => { let label = ctx.next_label(p); let mut pl = ctx.para_labelled(p, width, table, &[], label.clone()); let ctxl = pl.rp.contextual_spacing; let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl)); let before = if same && ctxl { 0.0 } else { pl.rp.space_before }; if same && ctxl { y -= prev_after; } // Word: space between paragraphs is before + after (no collapsing). y += if i == 0 { before } else { before.max(0.0) }; // Floating objects anchored here: place them, then wrap the text around them. // One left out of the layout (hidden, or deleted in the final text) takes no room. let mut floats = HashMap::new(); let left_out = left_out_objects(ctx.doc, p, table.map(|t| &t.chr), ctx.opts.show_hidden, ctx.hide_deleted); for (oi, o) in p.objects.iter().enumerate() { let Some((w, h, float)) = floating(o) else { continue }; if left_out(oi) { continue; } let r = float_rect(frame, (0.0, width), y, w, h, float); let outline = ctx.wrap_outline(o); excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice()))); floats.insert(oi, r); } let rel = rel_exclusions(&excl, 0.0, y); if !rel.is_empty() { pl = ctx.para_labelled(p, width, table, &rel, label); } let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0)); push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width); // Pictures, shapes and text boxes: drawn, their areas and text boxes' text. if !p.objects.is_empty() { let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr) }; let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth); let at = behind.min(items.len()); behind += back.len(); items.splice(at..at, back); items.extend(front); } y += pl.height + pl.rp.space_after; prev_after = if same && ctxl { 0.0 } else { pl.rp.space_after }; prev_style = Some((pl.rp.style.clone(), ctxl)); } Block::Table(t) => { if depth > 8 { continue; } let tl = table::layout_table(ctx, story, t, &path, width, depth + 1); let (tx, ty) = match t.props.float { // A floating table stands at its own position and takes no room; the text // after it wraps around it. Some(f) => { let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 }; let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables); excl.push((area, false, None, WrapText::BothSides)); float_tables.push(area); (r.x, r.y) } // A table in the text flow starts below the floating objects in its way. None => (tl.x, below(&excl, y, tl.x, tl.x + tl.width)), }; let mut row_y = ty; for row in &tl.rows { for it in &row.items { let mut it = it.clone(); it.translate(tx, row_y); items.push(it); } row_y += row.height; } if t.props.float.is_none() { y = row_y; prev_after = 0.0; prev_style = None; } } } } let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max); ctx.hide_deleted = outer_hide; BoxLayout { items, height: y.max(0.0), floats_bottom } } /// A laid-out box: items relative to its top-left, the height of its text flow, and where its /// floating tables end (a table cell grows to hold them). struct BoxLayout { items: Vec, height: f32, floats_bottom: f32, } /// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and /// the area text keeps clear of. Positions relative to the page or its margins need the box's /// `frame`; without one (a table cell) they're relative to the box. fn box_float_table_rect( frame: Option, width: f32, y: f32, tl: &table::TableLayout, f: &TableFloat, legacy: f32, float_tables: &[Rect], ) -> (Rect, Rect) { let h = tl.rows.iter().map(|r| r.height).sum::().clamp(0.0, 100_000.0); let w = tl.width.clamp(1.0, 100_000.0); let page_x = |s: &SectionProps| match f.h_rel { Anchor::Page => Some((0.0, s.page_w)), Anchor::Margin => Some((s.margin_left + s.gutter, s.text_width())), _ => None, }; let page_y = |s: &SectionProps| match f.v_rel { Anchor::Page => Some((0.0, s.page_h)), Anchor::Margin => Some((s.margin_top, s.text_height())), _ => None, }; let h_area = frame.and_then(|fr| page_x(fr.sect).map(|(a, e)| (a - fr.origin.0, e))).unwrap_or((0.0, width)); // In a cell an offset from the page or margin has nothing to measure from: the table stands // where the text is. let (v_area, y_off) = match frame.and_then(|fr| page_y(fr.sect).map(|(a, e)| (a - fr.origin.1, e))) { Some(area) => (area, f.y), None if matches!(f.v_rel, Anchor::Page | Anchor::Margin) => ((y, 0.0), 0.0), None => ((y, 0.0), f.y), }; // Nor is there an area to align in: the table starts where the text is. let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align }; let x = align_in(h_area, f.x, w, f.h_align) - if f.h_align.is_none() { legacy } else { 0.0 }; let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables); let [dl, dt, dr, db] = f.dist_from_text(); (r, r.inset(-dl, -dt, -dr, -db)) } /// Floating table `f` at `r`, moved below the floating tables in its way when it may not /// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's /// left edge (`text_left`). fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect { let [dl, dt, dr, db] = f.dist_from_text(); let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column); for _ in 0..64 { let area = r.inset(-dl, -dt, -dr, -db); let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break }; r.y = below + dt; if keeps_text_distance { r.x = r.x.max(text_left + dl); } } r } /// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom; a box's own /// are `TextBody::inset_pts`). #[cfg(test)] const BOX_INSET_X: f32 = 7.2; #[cfg(test)] const BOX_INSET_Y: f32 = 3.6; /// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are /// dropped (whole lines; a line that only partly fits too) and so is anything starting below. /// The first line always stays, so even a tiny box shows the start of its text. fn fit_box(items: Vec, height: f32) -> Vec { let limit = height + 0.5; let mut out = Vec::with_capacity(items.len()); let mut kept_line = false; for it in items { if let Some(r) = it.turned_bounds() { // Turned text (in a table cell) stays whole. if r.y < limit { 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 mut end = l0; for k in l0..l1 { let Some(l) = para.lines.get(k) else { break }; if y + (l.top - first.top) + l.height > limit && kept_line { break; } end = k + 1; kept_line = true; } if end > l0 { out.push(Placed::Lines { story, path, para, l0, l1: end, x, y, turn }); } } Placed::Fill { rect, color } if rect.y < limit => { out.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, rect.h.min(limit - rect.y)), color }); } Placed::Rule { x0, y0, x1, y1, border } if y0.min(y1) < limit => { out.push(Placed::Rule { x0, y0: y0.min(limit), x1, y1: y1.min(limit), border }); } Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. } | Placed::Cell { rect, .. } | Placed::Object { rect, .. } if rect.y < limit => { out.push(it); } _ => {} } } out } /// Paragraph lines plus their shading and borders. fn push_para(items: &mut Vec, story: StoryRef, path: &[u32], pl: &Arc, l0: usize, l1: usize, x: f32, y: f32, width: f32) { let (Some(first), Some(last)) = (pl.lines.get(l0), l1.checked_sub(1).and_then(|k| pl.lines.get(k))) else { return; }; let h = last.top + last.height - first.top; // Indents are logical: a right-to-left paragraph's start indent (and hanging indent) is on the right. let start = pl.rp.indent_left.min(pl.rp.indent_left + pl.rp.indent_first); let (li, ri) = if pl.rp.bidi { (pl.rp.indent_right, start) } else { (start, pl.rp.indent_right) }; let left = x + li; let right = x + width - ri; if let Some(c) = pl.rp.shading { let pad = pl.rp.borders.as_ref().map(|b| b.left.map(|l| l.space).unwrap_or(4.0)).unwrap_or(0.0); items.push(Placed::Fill { rect: Rect::new(left - pad, y, right - left + pad * 2.0, h), color: c }); } items.push(Placed::Lines { story, path: Path(path.to_vec()), para: pl.clone(), l0, l1, x, y, turn: TextDirection::Horizontal }); if let Some(b) = &pl.rp.borders { let sp = |o: &Option| o.map(|b| b.space).unwrap_or(0.0); let (lx, rx) = (left - sp(&b.left), right + sp(&b.right)); let (ty, by) = (y - sp(&b.top), y + h + sp(&b.bottom)); if let Some(t) = b.top.filter(|_| l0 == 0) && t.is_visible() { items.push(Placed::Rule { x0: lx, y0: ty, x1: rx, y1: ty, border: t }); } if let Some(t) = b.bottom.filter(|_| l1 >= pl.lines.len()) && t.is_visible() { items.push(Placed::Rule { x0: lx, y0: by, x1: rx, y1: by, border: t }); } if let Some(t) = b.left.filter(Border::is_visible) { items.push(Placed::Rule { x0: lx, y0: ty, x1: lx, y1: by, border: t }); } if let Some(t) = b.right.filter(Border::is_visible) { items.push(Placed::Rule { x0: rx, y0: ty, x1: rx, y1: by, border: t }); } } } #[derive(Clone)] struct PageBuilder<'a> { pages: Vec, sect: &'a SectionProps, sect_idx: usize, col: usize, cols: Vec<(f32, f32)>, y: f32, top: f32, bottom: f32, number: u32, web: bool, /// Footnotes waiting for the bottom of the current page (first the rest of notes continued /// from the page before). notes: Vec, /// The parts of footnotes that didn't fit on their page, continued on the next one. carry: Vec, /// Page bottom before footnotes took space. orig_bottom: f32, /// The previous paragraph: (style, contextual spacing, space after) for contextual spacing. prev: Option<(String, bool, f32)>, /// Wrap areas of floating objects on this page (see [`wrap_area`]). excl: Vec, /// Wrap areas of the floating tables on this page, which others may not overlap. float_tables: Vec, /// Line numbering counter. line_no: u32, /// Index of the first body item of the current page (vertical alignment shifts from here). page_items_start: usize, /// The document's own footnote separators, laid out for the section's text width. seps: NoteSeps, /// Per page: how many items were inserted at the front of its list (behind the text), so a /// [`Snap`] can take them out again. behind: Vec, /// The bottom of the tallest column on the current page before the current one. max_y: f32, /// The bottom of the lowest line or table row placed on the current page (without the space /// after it). content_bottom: f32, /// Balancing columns: (page index, column height) — that page's columns end `height` below /// the top of the section on it. balance: Option<(usize, f32)>, /// The page with the last explicit column break. col_break_page: Option, /// The section's footnotes go right below its text, not at the page bottom ... sect_beneath: bool, /// ... and so do the current page's (set as the page starts). beneath: bool, /// Top of the footer of the pages being started: a footer reaching above the bottom margin /// pushes the body up (`f32::MAX`: no footer). foot_top: f32, } /// Gap above the footnote separator and its length. const NOTE_SEP: f32 = 12.0; /// Length of the default (short) note separator line. pub(crate) const NOTE_SEP_W: f32 = 144.0; /// The story of text drawn on a page that belongs to no story of the document (line numbers, /// note separators and continuation notices): not hit by clicks. pub const DECOR_STORY: StoryRef = StoryRef::Part(u32::MAX); /// A note separator or continuation notice laid out from y = 0 (its items and height). type SepBox = (Vec, f32); /// The document's own footnote or endnote separators and continuation notice, laid out (`None`: /// the defaults). #[derive(Clone, Default)] struct NoteSeps { sep: Option, cont: Option, notice: Option, } impl NoteSeps { /// Lay out `seps` `width` wide. fn new(ctx: &mut Ctx, seps: &wordcraft_doc::NoteSeparators, width: f32) -> NoteSeps { let mut lay = |blocks: &Option| blocks.as_ref().map(|b| sep_box(ctx, b, width)); NoteSeps { sep: lay(&seps.separator), cont: lay(&seps.continuation_separator), notice: lay(&seps.continuation_notice) } } /// Height of the separator above a page's notes (`cont`: the continuation separator, when the /// first is continued from the page before). fn sep_h(&self, cont: bool) -> f32 { let own = if cont { &self.cont } else { &self.sep }; own.as_ref().map_or(NOTE_SEP, |s| s.1) } /// Height of the notice below a note that continues on the next page. fn notice_h(&self) -> f32 { self.notice.as_ref().map_or(0.0, |s| s.1) } } /// A note separator or continuation notice story (`blocks`) laid out `width` wide. fn sep_box(ctx: &mut Ctx, blocks: &Blocks, width: f32) -> SepBox { let bl = layout_box(ctx, DECOR_STORY, blocks, &[], width, None, 0, None); let mut items = bl.items; items.extend(separator_rules(blocks, &items)); (items, bl.height) } /// The lines drawn by the note separators (see [`InlineObject::note_separator`]) in a separator /// story's laid-out `items` (`blocks` its paragraphs): across each one's place in its line, half /// way down the line. fn separator_rules(blocks: &Blocks, items: &[Placed]) -> Vec { let mut rules = Vec::new(); for it in items { let Placed::Lines { para, path, l0, l1, x, y, turn: TextDirection::Horizontal, .. } = it else { continue }; let [i] = path.0.as_slice() else { continue }; let Some(Block::Para(p)) = blocks.get(*i as usize).map(|b| &**b) else { continue }; let Some(first) = para.lines.get(*l0) else { continue }; for line in para.lines.get(*l0..*l1).unwrap_or(&[]) { for k in line.c0..line.c1 { let Some(para::ClKind::Object(oi)) = para.clusters.get(k).map(|c| c.kind) else { continue }; if p.objects.get(oi).and_then(InlineObject::as_note_separator).is_none() { continue; } let (Some(a), Some(b)) = (line.cl_left(k), line.cl_right(k)) else { continue }; let ry = y + line.top - first.top + line.height / 2.0; rules.push(Placed::Rule { x0: x + a.min(b), y0: ry, x1: x + a.max(b), y1: ry, border: Border::single(0.5) }); } } } rules } /// The most pages added after the body for footnotes still continuing. const MAX_NOTE_PAGES: usize = 10_000; /// A footnote waiting for the bottom of a page: its items laid out from y = 0, and its height. #[derive(Clone)] struct NoteBox { id: u32, items: Vec, h: f32, /// The rest of a note that didn't fit on an earlier page (drawn below the continuation /// separator). cont: bool, } /// The vertical extent of an item laid out in a horizontal frame. fn v_span(it: &Placed) -> (f32, f32) { if let Some(r) = it.turned_bounds() { return (r.y, r.y + r.h); } match it { Placed::Lines { para, l0, l1, y, .. } => (*y, item_bottom(*y, para, *l0, *l1).unwrap_or(*y)), Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. } | Placed::Cell { rect, .. } | Placed::Object { rect, .. } => (rect.y, rect.y + rect.h), Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)), } } impl NoteBox { /// Lines of the note's own paragraphs, which may be split between pages (not those of its /// tables or text boxes). fn splittable(&self, it: &Placed) -> bool { matches!(it, Placed::Lines { story: StoryRef::Part(s), path, turn: TextDirection::Horizontal, .. } if *s == self.id && path.0.len() == 1) } /// Where the note may be split, ascending: the bottoms of its lines that have more lines /// below them and don't cut through a picture, table or other unsplittable item. fn cuts(&self) -> Vec { let blocked: Vec<(f32, f32)> = self.items.iter().filter(|it| !self.splittable(it) && !matches!(it, Placed::Fill { .. } | Placed::Rule { .. })).map(v_span).collect(); let mut last_top = f32::MIN; let mut cuts = Vec::new(); for it in &self.items { let Placed::Lines { para, l0, l1, y, .. } = it else { continue }; if !self.splittable(it) { continue; } let Some(first) = para.lines.get(*l0) else { continue }; for l in para.lines.get(*l0..*l1).unwrap_or(&[]) { last_top = last_top.max(y + l.top - first.top); cuts.push(y + l.top - first.top + l.height); } } cuts.retain(|&b| b > 0.01 && b <= last_top + 0.01 && !blocked.iter().any(|&(t, e)| t < b - 0.01 && e > b + 0.01)); cuts.sort_by(f32::total_cmp); cuts.dedup_by(|a, b| (*a - *b).abs() < 0.01); cuts } /// The least of the note that must stay with its reference: its first line (all of it if it /// can't be split). fn first_piece(&self) -> f32 { self.cuts().first().copied().unwrap_or(self.h).min(self.h) } /// Split the note at line bottom `cut`: the part above (`cut` tall) and the rest, moved up to /// start at 0 and marked as a continuation. fn split(self, cut: f32) -> (NoteBox, NoteBox) { let (mut head, mut tail) = (Vec::new(), Vec::new()); for it in self.items { let (top, bottom) = v_span(&it); if bottom <= cut + 0.01 { head.push(it); continue; } if top >= cut - 0.01 { tail.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 ft = first.top; // The first line that ends below the cut starts the tail. let k = (l0..l1).find(|&k| para.lines.get(k).is_none_or(|l| y + l.top - ft + l.height > cut + 0.01)).unwrap_or(l1); let ky = para.lines.get(k).map_or(y, |l| y + l.top - ft); if k > l0 { head.push(Placed::Lines { story, path: path.clone(), para: para.clone(), l0, l1: k, x, y, turn }); } if k < l1 { tail.push(Placed::Lines { story, path, para, l0: k, l1, x, y: ky, turn }); } } Placed::Fill { rect, color } => { head.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, cut - rect.y), color }); tail.push(Placed::Fill { rect: Rect::new(rect.x, cut, rect.w, rect.y + rect.h - cut), color }); } Placed::Rule { x0, y0, x1, y1, border } => { head.push(Placed::Rule { x0, y0: y0.min(cut), x1, y1: y1.min(cut), border }); tail.push(Placed::Rule { x0, y0: y0.max(cut), x1, y1: y1.max(cut), border }); } // Not split (cuts avoid them): stays above. other => head.push(other), } } // The rest starts at its first line (the space between paragraphs goes with the break). let start = tail.iter().filter(|it| matches!(it, Placed::Lines { .. })).map(|it| v_span(it).0).fold(f32::MAX, f32::min); let start = if start == f32::MAX { cut } else { start.max(cut) }; for it in &mut tail { it.translate(0.0, -start); // Shading cut at the break starts at the rest's top. if let Placed::Fill { rect, .. } = it && rect.y < 0.0 { rect.h = (rect.h + rect.y).max(0.0); rect.y = 0.0; } } let rest = (self.h - start).max(0.0); (NoteBox { id: self.id, items: head, h: cut, cont: self.cont }, NoteBox { id: self.id, items: tail, h: rest, cont: true }) } } /// Page borders, measured from the page edge (Word's default), so they surround the header, /// footer and line numbers. The first items of every printed page. fn page_border_rules(s: &SectionProps) -> Vec { let mut v = Vec::new(); if let Some(b) = &s.page_borders { let sp = |e: &Option| e.map(|x| x.space).unwrap_or(24.0); let (x0, x1, y0, y1) = (sp(&b.left), s.page_w - sp(&b.right), sp(&b.top), s.page_h - sp(&b.bottom)); for (e, a, bb) in [(b.top, (x0, y0), (x1, y0)), (b.bottom, (x0, y1), (x1, y1)), (b.left, (x0, y0), (x0, y1)), (b.right, (x1, y0), (x1, y1))] { if let Some(e) = e.filter(Border::is_visible) { v.push(Placed::Rule { x0: a.0, y0: a.1, x1: bb.0, y1: bb.1, border: e }); } } } v } impl PageBuilder<'_> { /// Space footnotes take at the bottom of the page. fn notes_h(&self) -> f32 { match self.notes.first() { None => 0.0, Some(first) => self.seps.sep_h(first.cont) + self.notes.iter().map(|n| n.h).sum::(), } } /// The lowest bottom of the body text and tables on the current page. fn body_floor(&self) -> f32 { let Some(pg) = self.pages.last() else { return self.top }; pg.items .iter() .skip(self.page_items_start) .filter(|it| matches!(it, Placed::Lines { story: StoryRef::Body, .. } | Placed::Cell { story: StoryRef::Body, .. })) .map(|it| v_span(it).1) .fold(self.top, f32::max) } /// Place the collected footnotes at the bottom of the current page. When they don't all fit /// below the body (`bottom`), the note that doesn't is split at a line and its rest, with /// the notes after it, continues on the next page. fn flush_notes(&mut self) { if self.notes.is_empty() { return; } let total = self.notes_h(); let x = self.sect.margin_left + self.sect.gutter; // The note area starts below the text (a page of continued notes alone: at its top). let area = self.bottom.max(self.body_floor()); let fits = total <= self.orig_bottom - area + 0.01; let top = match (fits, self.beneath) { (false, _) => area, // Below the page's last line of text. (true, true) => self.body_floor().min(self.orig_bottom - total), (true, false) => self.orig_bottom - total, }; let notes = std::mem::take(&mut self.notes); // A page that starts with a continued note gets the continuation separator. let cont = notes.first().is_some_and(|n| n.cont); let mut y = top + self.seps.sep_h(cont); let mut placed = Vec::new(); // Where the continuation notice goes, below a note that continues on the next page. let mut notice_y = None; let mut rest = notes.into_iter(); while let Some(n) = rest.next() { let room = self.orig_bottom - y; if fits || n.h <= room + 0.01 { let ny = y; y += n.h; placed.push((n, ny)); continue; } // Split at the last line that fits with the notice below it. The page's first note // always leaves something here (its first line, or all of it if it can't be split), // so continuing ends. let first = placed.is_empty(); let cuts = n.cuts(); let room = room - self.seps.notice_h(); match cuts.iter().rev().find(|&&c| c <= room + 0.01).or(cuts.first().filter(|_| first)).copied() { Some(c) => { let (head, tail) = n.split(c); notice_y = Some(y + head.h); placed.push((head, y)); self.carry.push(tail); } None if first => placed.push((n, y)), None => self.carry.push(n), } self.carry.extend(rest.by_ref()); break; } if let Some(pg) = self.pages.last_mut() { match if cont { &self.seps.cont } else { &self.seps.sep } { Some((items, _)) => pg.items.extend(items.iter().cloned().map(|mut it| { it.translate(x, top); it })), None => { let len = if cont { self.sect.text_width() } else { NOTE_SEP_W }; let sy = top + NOTE_SEP / 2.0; pg.items.push(Placed::Rule { x0: x, y0: sy, x1: x + len, y1: sy, border: Border::single(0.5) }); } } for (n, ny) in placed { for mut it in n.items { it.translate(x, ny); pg.items.push(it); } } if let (Some(ny), Some((items, _))) = (notice_y, &self.seps.notice) { pg.items.extend(items.iter().cloned().map(|mut it| { it.translate(x, ny); it })); } } } /// Section vertical alignment: move the page's body down (centre/bottom). fn apply_valign(&mut self) { let k = match self.sect.valign { wordcraft_doc::props::VAlign::Top => return, wordcraft_doc::props::VAlign::Center => 0.5, wordcraft_doc::props::VAlign::Bottom => 1.0, }; if self.web { return; } let space = (self.orig_bottom - self.notes_h() - self.y).max(0.0) * k; let start = self.page_items_start; if let Some(pg) = self.pages.last_mut() { for it in pg.items.iter_mut().skip(start) { it.translate(0.0, space); } } } fn new_page(&mut self, first_block: usize, body_top: f32) { self.apply_valign(); self.flush_notes(); self.beneath = self.sect_beneath; self.excl.clear(); self.float_tables.clear(); if self.sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Page) { self.line_no = 0; } let s = self.sect; self.number = self.number.saturating_add(1); let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) }; // A footer taller than the bottom margin pushes the body up (Word: no gap), leaving it at // least a little room. let bottom = (s.page_h - s.margin_bottom).min(self.foot_top.max(body_top + 36.0)); let body = Rect::new(s.margin_left + s.gutter, body_top, s.text_width(), (bottom - body_top).max(36.0)); let mut decor = Vec::new(); if !self.web { decor = page_border_rules(s); // Lines between columns. if s.columns.separator && s.columns.count > 1 { let cols = s.column_boxes(); for w in cols.windows(2) { if let [(ax, aw), (bx, bw)] = w { // Midway across the space between neighbours (leftwards in an RTL section). let mid = if bx >= ax { (ax + aw + bx) / 2.0 } else { (bx + bw + ax) / 2.0 }; let x = s.margin_left + s.gutter + mid; decor.push(Placed::Rule { x0: x, y0: body_top, x1: x, y1: bottom, border: Border::single(0.5) }); } } } } self.page_items_start = decor.len(); self.pages.push(Page { w, h, section: self.sect_idx, number: self.number, body, first_block, items: decor, ..Default::default() }); self.behind.push(0); self.col = 0; self.cols = s.column_boxes(); self.top = body_top; self.bottom = if self.web { f32::MAX / 8.0 } else { bottom }; self.orig_bottom = self.bottom; self.y = self.top; // Notes continued from the page before come first in this page's note area. // (`cap_columns` below keeps the columns above them.) if !self.carry.is_empty() && !self.web { self.notes = std::mem::take(&mut self.carry); } self.max_y = self.top; self.content_bottom = self.top; self.cap_columns(); } /// Where the columns end on this page: the page bottom above its footnotes, or higher up /// when the columns are balanced. fn cap_columns(&mut self) { self.bottom = self.uncapped_bottom(); if let Some((page, h)) = self.balance && page + 1 == self.pages.len() { self.bottom = self.bottom.min(self.top + h); } } /// The bottom of the columns without balancing: above the footnotes. fn uncapped_bottom(&self) -> f32 { self.orig_bottom - self.notes_h() } /// Below the text on this page: the tallest column's bottom. fn text_bottom(&self) -> f32 { self.max_y.max(self.y) } fn col_x(&self) -> f32 { self.sect.margin_left + self.sect.gutter + self.cols.get(self.col).map(|c| c.0).unwrap_or(0.0) } fn col_w(&self) -> f32 { self.cols.get(self.col).map(|c| c.1).unwrap_or_else(|| self.sect.text_width()) } fn at_top(&self) -> bool { self.y <= self.top + 0.01 } /// Move to the next column or page. fn advance(&mut self, block: usize, body_top: f32) { if self.col + 1 < self.cols.len() { self.max_y = self.max_y.max(self.y); self.col += 1; self.y = self.top; } else { self.new_page(block, body_top); } } fn page(&mut self) -> Option<&mut Page> { self.pages.last_mut() } } /// Passes the layout makes at most for footnotes numbered again on each page: their numbers /// depend on the pages they land on, which (barely) depend on the numbers' widths. const MAX_NOTE_PASSES: usize = 3; /// Lay out the whole document. pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout { let t0 = now_ms(); // Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after // paginating: lay out again with the totals found until they hold (bounded). Footnotes // numbered again on each page likewise settle over a few more passes. let per_page = opts.view == ViewMode::Print && doc.sections().iter().any(|(_, s)| doc.note_options(s, false).num_restart == wordcraft_doc::section::NoteRestart::EachPage); let mut note_pages = HashMap::new(); let mut note_passes = 1; let mut totals = Totals::first_guess(doc.sections().len()); let mut passes = 0; let mut l = loop { match layout_pass(doc, cache, opts, ¬e_pages, &totals, passes < MAX_TOTAL_PASSES) { Pass::Again(t) => { totals = t; passes += 1; } Pass::Done(l) => { if per_page && note_passes < MAX_NOTE_PASSES { // Number each page's footnotes from where the pass before placed them, until they stay. let placed = footnote_pages(doc, &l.pages); if placed != note_pages { note_pages = placed; note_passes += 1; continue; } } break l; } } }; l.ms = now_ms() - t0; l } /// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]), /// with `totals` assumed for the page totals in the body. With `may_repeat`, a pass whose body /// shows totals that turn out different asks to be done again. fn layout_pass( doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, note_pages: &HashMap, totals: &Totals, may_repeat: bool, ) -> Pass { let env = env_hash(doc, opts); if env != cache.env { cache.paras.clear(); cache.env = env; } cache.used.clear(); // Note references are counted as the body shows them. let notes = note_info(doc, opts.hides_deleted(StoryRef::Body), note_pages); let notes_hash = hash_of(&{ let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, b.n, b.fmt)).collect(); v.sort_by_key(|(a, ..)| *a); v }); let mut ctx = Ctx { doc, cache, opts, hide_deleted: opts.hides_deleted(StoryRef::Body), counters: Counters::default(), fields: FieldCtx { page: 1, pages: totals.pages, section_pages: totals.section(0), section: 1, notes: Arc::new(notes), title: doc.core.title.as_str().into(), author: doc.core.creator.as_str().into(), ..Default::default() }, notes_hash, numbers: HashMap::new(), eq_count: 0, boxes: wordcraft_doc::BoxBudget::default(), chains: Default::default(), grid: para::Grid::default(), uses_totals: false, track_totals: true, }; let sections = doc.sections(); let web = opts.view != ViewMode::Print; let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default())); let default_sect = SectionProps::default(); let mut web_sect; let first_sect = match sections.first() { Some((_, s)) => *s, None => &default_sect, }; let sect_ref = if web { web_sect = first_sect.clone(); web_sect.page_w = opts.web_width.max(144.0); web_sect.margin_left = 18.0; web_sect.margin_right = 18.0; web_sect.margin_top = 18.0; web_sect.gutter = 0.0; web_sect.columns = Default::default(); &web_sect } else { first_sect }; let mut pb = PageBuilder { pages: Vec::new(), sect: sect_ref, sect_idx: 0, col: 0, cols: Vec::new(), y: 0.0, top: 0.0, bottom: 0.0, number: 0, web, notes: Vec::new(), carry: Vec::new(), orig_bottom: 0.0, prev: None, excl: Vec::new(), float_tables: Vec::new(), line_no: 0, page_items_start: 0, seps: NoteSeps::default(), behind: Vec::new(), max_y: 0.0, content_bottom: 0.0, balance: None, col_break_page: None, sect_beneath: false, beneath: false, foot_top: f32::MAX, }; // Endnotes in document order, with the section of their reference; placed at the end of a // section that keeps them there, else at the end of the document. let mut endnotes: std::collections::VecDeque<(u32, usize)> = { let mut ids: Vec<(u32, u32, usize)> = ctx .fields .notes .iter() .filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote)) .map(|(id, n)| (n.order, *id, n.section)) .collect(); ids.sort(); ids.into_iter().map(|(_, id, sect)| (id, sect)).collect() }; // Text boxes laid out in balancing attempts that were thrown away. let mut boxes_discarded = 0usize; let mut block = 0usize; // Vertical sections are laid out on their pages turned on their side (see `vertical`). let turned: Vec> = sections .iter() .map(|(_, s)| { (!web && s.text_direction.is_turned()).then(|| { let top = body_top_for(&mut ctx, s, s.headers.default); vertical::unturned_sect(s, top) }) }) .collect(); let mut prev_turn = TextDirection::Horizontal; for (si, (end, sect)) in sections.iter().enumerate() { let turn = if web { TextDirection::Horizontal } else { sect.text_direction }; let sect: &SectionProps = match turned.get(si) { Some(Some(t)) => t, _ if web => sect_ref, _ => sect, }; pb.sect = sect; pb.sect_idx = si; ctx.grid = para::Grid::default(); let (foot, end_opts) = (doc.note_options(sect, false), doc.note_options(sect, true)); pb.sect_beneath = foot.pos == wordcraft_doc::section::NotePos::BeneathText; if !web && !doc.footnote_separators.is_empty() { pb.seps = NoteSeps::new(&mut ctx, &doc.footnote_separators, sect.text_width()); } ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1); ctx.fields.section_pages = totals.section(si); // Headers and footers left out are linked to the previous section's, and take its room. let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers)); let body_top = if web || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) }; let first_top = if web || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) }; let foot_top = if web || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) }; let first_foot = if web || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) }; ctx.grid = para::Grid::of(doc, sect); let restart = sect.page_num_start; if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) { pb.line_no = 0; } let start = if si == 0 { SectionStart::NextPage } else { sect.start }; if web && si > 0 { // one long page } else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn { // A page holds text running one way: a section turning the other way starts a page. if let Some(n) = restart { pb.number = n.saturating_sub(1); } pb.foot_top = first_foot; pb.new_page(block, first_top); pb.foot_top = foot_top; if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) { let want_even = start == SectionStart::EvenPage; if pb.number.is_multiple_of(2) != want_even { pb.new_page(block, body_top); } } } else { // Continuous: new column layout below the current text (its tallest column). pb.cols = sect.column_boxes(); pb.col = 0; pb.top = pb.text_bottom(); pb.y = pb.top; pb.max_y = pb.top; } prev_turn = turn; pb.foot_top = foot_top; let last = (*end).min(doc.body.len().saturating_sub(1)); // Newspaper columns are balanced on the section's last page when the next section starts // on the same page (a continuous break), as in Word. That page is laid out again from the // start of the block that reaches it, with shorter columns (see `balance_columns`). let balance = !web && pb.cols.len() > 1 && sect.valign == wordcraft_doc::props::VAlign::Top && sections.get(si + 1).is_some_and(|(_, s)| s.start == SectionStart::Continuous); pb.col_break_page = None; let mut from = balance.then(|| (block, Snap::take(&mut pb, &ctx))); while block <= last { let Some(b) = doc.body.get(block) else { break }; let before = balance.then(|| (pb.pages.len(), Snap::take(&mut pb, &ctx))); match (&**b, &outline) { (Block::Para(p), Some(o)) => place_outline_para(&mut ctx, &mut pb, p, block, o), (Block::Table(_), Some(o)) if o.hidden.get(block).copied().unwrap_or(false) => {} (Block::Para(p), None) => place_para(&mut ctx, &mut pb, p, block, body_top), (Block::Table(t), _) => place_table(&mut ctx, &mut pb, t, block, body_top), } if let Some((pages, snap)) = before && pb.pages.len() != pages { from = Some((block, snap)); } block += 1; } if let Some((first, snap)) = from { boxes_discarded += balance_columns(&mut ctx, &mut pb, (first, block), &snap, body_top); } // Endnotes at the end of the section: those referenced so far. if end_opts.pos == wordcraft_doc::section::NotePos::SectEnd && !pb.pages.is_empty() { let mut here = Vec::new(); while let Some((id, _)) = endnotes.front().filter(|(_, s)| *s <= si).copied() { endnotes.pop_front(); here.push(id); } place_endnotes(&mut ctx, &mut pb, here, block.saturating_sub(1)); } } if pb.pages.is_empty() { pb.new_page(0, sect_ref.margin_top); } // The rest of the endnotes after the last paragraph. let rest: Vec = endnotes.into_iter().map(|(id, _)| id).collect(); place_endnotes(&mut ctx, &mut pb, rest, block); pb.apply_valign(); pb.flush_notes(); // Footnotes still continuing after the last page get pages of their own. Each page takes at // least a line of the first note, so this ends; the cap is a backstop. let last_block = doc.body.len().saturating_sub(1); for _ in 0..MAX_NOTE_PAGES { if pb.carry.is_empty() { break; } let top = pb.top; pb.new_page(last_block, top); pb.flush_notes(); } pb.carry.clear(); let mut pages = pb.pages; for p in &mut pages { if let Some((_, s)) = sections.get(p.section) && !web { vertical::turn_page(p, s.text_direction, s); } } if web && let Some(p) = pages.first_mut() { let bottom = p .items .iter() .filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None }) .fold(0.0f32, f32::max); p.h = bottom + 36.0; } ctx.grid = para::Grid::default(); if !web && ctx.uses_totals && may_repeat { let found = Totals::of(&pages, sections.len()); if found != *totals { return Pass::Again(found); } } if !web { ctx.track_totals = false; headers_footers(&mut ctx, &mut pages, §ions); if doc.settings.mirror_margins { mirror_even_pages(&mut pages, §ions); } } // Evict paragraph layouts that weren't used this pass if the cache grew large. if ctx.cache.paras.len() > 4 * ctx.cache.used.len().max(1024) { let used = std::mem::take(&mut ctx.cache.used); ctx.cache.paras.retain(|k, _| used.contains(k)); ctx.cache.used = used; } let mut index: HashMap<(StoryRef, Path), Vec<(usize, usize)>> = HashMap::new(); for (pi, p) in pages.iter().enumerate() { for (ii, it) in p.items.iter().enumerate() { if let Placed::Lines { story, path, .. } = it { index.entry((*story, path.clone())).or_default().push((pi, ii)); } } } let mut lay = DocLayout { pages, index, ms: 0.0, text_boxes: 0 }; if opts.comment_balloons && !web { balloons::add(&mut ctx, &mut lay); } lay.text_boxes = ctx.boxes.total().saturating_sub(boxes_discarded); Pass::Done(lay) } /// Pagination state saved before a block, to lay the blocks from there out again (balancing /// columns): the builder without its pages, how far its last page had got, and the walk state. struct Snap<'a> { pb: PageBuilder<'a>, pages: usize, items: usize, behind: usize, counters: Counters, eq_count: u32, page_field: u32, boxes: usize, } impl<'a> Snap<'a> { fn take(pb: &mut PageBuilder<'a>, ctx: &Ctx) -> Self { // The pages stay out of the copy: it would cost the whole document per block. let (pages, behind) = (std::mem::take(&mut pb.pages), std::mem::take(&mut pb.behind)); let copy = pb.clone(); (pb.pages, pb.behind) = (pages, behind); Snap { pb: copy, pages: pb.pages.len(), items: pb.pages.last().map_or(0, |p| p.items.len()), behind: pb.behind.last().copied().unwrap_or(0), counters: ctx.counters.clone(), eq_count: ctx.eq_count, page_field: ctx.fields.page, boxes: ctx.boxes.total(), } } /// Go back to this state: drop the pages added since and what was added to the last one /// (appended, or inserted at the front behind the text). fn restore(&self, pb: &mut PageBuilder<'a>, ctx: &mut Ctx) { let (mut pages, mut behind) = (std::mem::take(&mut pb.pages), std::mem::take(&mut pb.behind)); pages.truncate(self.pages); behind.truncate(self.pages); if let Some(pg) = pages.last_mut() { let k = behind.last().copied().unwrap_or(0).saturating_sub(self.behind).min(pg.items.len()); pg.items.drain(..k); pg.items.truncate(self.items); } if let Some(b) = behind.last_mut() { *b = self.behind; } *pb = self.pb.clone(); (pb.pages, pb.behind) = (pages, behind); ctx.counters = self.counters.clone(); ctx.eq_count = self.eq_count; ctx.fields.page = self.page_field; } } /// One balancing pass: blocks `first..end` laid out again from `snap` with the columns on `page` /// ending `h` below `top`; true if the page's text fits them. fn balance_pass<'a>( ctx: &mut Ctx, pb: &mut PageBuilder<'a>, snap: &Snap<'a>, (first, end): (usize, usize), (page, top, h): (usize, f32, f32), body_top: f32, ) -> bool { snap.restore(pb, ctx); pb.balance = Some((page, h)); pb.cap_columns(); for block in first..end { match ctx.doc.body.get(block).map(|b| &**b) { Some(Block::Para(p)) => place_para(ctx, pb, p, block, body_top), Some(Block::Table(t)) => place_table(ctx, pb, t, block, body_top), None => break, } // Spilled onto another page: too short (and no need to lay out the rest). if pb.pages.len() > page + 1 { break; } } pb.balance = None; pb.cap_columns(); pb.pages.len() == page + 1 && pb.content_bottom <= top + h + 0.5 } /// Balancing lays out at most this many pages again per pass (a block reaching the last page /// from further back is left unbalanced), so its cost stays a small multiple of one page. const BALANCE_MAX_PAGES: usize = 16; /// Passes of the binary search on the column height (enough for well under a point). const BALANCE_PASSES: usize = 12; /// Balance the columns on the last page of a section followed by a continuous break, as Word /// does: lay blocks `first..end` out again from `snap` (taken before `first`) with the shortest /// columns that still hold that page's text — a binary search on the column height, where a /// height fits when nothing moves to another page and nothing (an unsplittable row, a line /// taller than the column) runs past it. Keep-together, keep-with-next and widow/orphan control /// apply as usual, so the columns may come out a little uneven. Not balanced: a page with an /// explicit column break, which says where the columns end. Returns the text boxes laid out in the /// passes thrown away. fn balance_columns<'a>(ctx: &mut Ctx, pb: &mut PageBuilder<'a>, blocks: (usize, usize), snap: &Snap<'a>, body_top: f32) -> usize { let page = pb.pages.len().saturating_sub(1); let ncols = pb.cols.len().max(1) as f32; let top = pb.top; let full = pb.uncapped_bottom() - top; let used = pb.content_bottom.min(pb.uncapped_bottom()) - top; if pb.col_break_page == Some(page) || snap.pages == 0 || (page + 1).saturating_sub(snap.pages) > BALANCE_MAX_PAGES || used <= 1.0 || full <= 1.0 || pb.cols.len() < 2 { return 0; } let pass = |ctx: &mut Ctx, pb: &mut PageBuilder<'a>, h: f32| balance_pass(ctx, pb, snap, blocks, (page, top, h), body_top); // The text on the page fills `ncols` columns at least `used / ncols` high; the original layout // (`full`) fits, and so, nearly always, do columns as high as its tallest one. let mut lo = (used / ncols - 1.0).max(0.0); let mut hi = full; let mut last = None; let discard = |ctx: &Ctx| ctx.boxes.total().saturating_sub(snap.boxes); let mut thrown = discard(ctx); if used < full - 0.5 { let ok = pass(ctx, pb, used); if ok { hi = used; } last = Some((used, ok)); } for _ in 0..BALANCE_PASSES { if hi - lo <= 1.0 { break; } let mid = (lo + hi) / 2.0; thrown = discard(ctx); let ok = pass(ctx, pb, mid); if ok { hi = mid; } else { lo = mid; } last = Some((mid, ok)); } // End on the best height (the full column, as laid out first, when none was shorter). if last != Some((hi, true)) { thrown = discard(ctx); if !pass(ctx, pb, hi) && hi < full { thrown = discard(ctx); pass(ctx, pb, full); } } thrown } /// Place endnotes `ids` below the text so far: a separator (the document's own, else a short /// line), then each note, moving to the next column or page when one doesn't fit. fn place_endnotes(ctx: &mut Ctx, pb: &mut PageBuilder, ids: Vec, block: usize) { if ids.is_empty() || pb.web { return; } // The document's own endnote separator, else a short line. let width = pb.col_w(); if let Some((items, h)) = ctx.doc.endnote_separators.separator.as_ref().map(|b| sep_box(ctx, b, width)) { let (x, y) = (pb.col_x(), pb.y); if let Some(pg) = pb.page() { pg.items.extend(items.into_iter().map(|mut it| { it.translate(x, y); it })); } pb.y += h; } else { pb.y += 12.0; let (x, ry) = (pb.col_x(), pb.y); if let Some(pg) = pb.page() { pg.items.push(Placed::Rule { x0: x, y0: ry, x1: x + NOTE_SEP_W, y1: ry, border: Border::single(0.5) }); } pb.y += 8.0; } let mut added = 0usize; for id in ids { let Some(part) = ctx.doc.parts.get(&id) else { continue }; let blocks = part.blocks.clone(); let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0, None); // A note longer than the room left continues at a line break in the next column or on // a new page (Word). Each page takes at least a line, so this ends; the page cap is a // backstop (the rest then runs off the last page). let mut note = NoteBox { id, items, h, cont: false }; loop { let room = pb.bottom - pb.y; let cut = if note.h <= room + 0.01 || added >= MAX_NOTE_PAGES { None } else { let cuts = note.cuts(); match cuts.iter().rev().find(|&&c| c <= room + 0.01).or(cuts.first().filter(|_| pb.at_top())) { Some(&c) => Some(c), None if pb.at_top() => None, None => { pb.advance(block, pb.top); added += 1; continue; } } }; let (head, rest) = match cut { Some(c) => { let (head, rest) = note.split(c); (head, Some(rest)) } None => (note, None), }; let (x, y) = (pb.col_x(), pb.y); if let Some(pg) = pb.page() { for mut it in head.items { it.translate(x, y); pg.items.push(it); } } pb.y += head.h; let Some(rest) = rest else { break }; note = rest; pb.advance(block, pb.top); added += 1; } } } fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option { let f = para.lines.get(l0)?; let l = para.lines.get(l1.checked_sub(1)?)?; Some(y + l.top + l.height - f.top) } /// Where body text starts: the top margin, or below the header if it's taller. fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option) -> f32 { let Some(id) = header else { return sect.margin_top }; let Some(part) = ctx.doc.parts.get(&id) else { return sect.margin_top }; let blocks = part.blocks.clone(); // Placed as `headers_footers` draws it, so page-relative floats wrap the same way. let frame = PageFrame { sect, origin: (sect.margin_left + sect.gutter, sect.header) }; let track = std::mem::replace(&mut ctx.track_totals, false); let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height; ctx.track_totals = track; // Word starts the body right below a header that reaches past the top margin, no gap. sect.margin_top.max(sect.header + h) } /// Where the footer starts (as `headers_footers` places it), `f32::MAX` without one. fn foot_top_for(ctx: &mut Ctx, sect: &SectionProps, footer: Option) -> f32 { let Some(part) = footer.and_then(|id| ctx.doc.parts.get(&id).map(|p| (id, p))) else { return f32::MAX }; let (id, blocks) = (part.0, part.1.blocks.clone()); // A page total in the footer is filled in after pagination, like the header's: it doesn't // make the body lay out again. let track = std::mem::replace(&mut ctx.track_totals, false); let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, None).height; ctx.track_totals = track; sect.page_h - sect.footer - h } /// Where a box of laid-out items sits on its page, for floating objects positioned relative to /// the page or its margins. #[derive(Clone, Copy)] struct PageFrame<'a> { sect: &'a SectionProps, /// Page position of the box's (0, 0); (0, 0) for body text. origin: (f32, f32), } /// A floating (not inline) picture, chart or shape: its size and placement. fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> { o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline) } /// Where a floating object goes, in the coordinates of the box `frame` places on the page. /// `col` is the column (or cell) left edge and width, `y0` the anchor paragraph's top. Without a /// frame (table cells, text boxes, notes) positions relative to the page or margins fall back /// to the box itself. fn float_rect(frame: Option, col: (f32, f32), y0: f32, w: f32, h: f32, float: &Float) -> Rect { let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); // Each axis: the reference area's start and extent, in page coordinates. let page_x = |s: &SectionProps| { let left = s.margin_left + s.gutter; match float.h_rel { Anchor::Page => Some((0.0, s.page_w)), Anchor::Margin => Some((left, s.text_width())), Anchor::LeftMargin | Anchor::InsideMargin => Some((0.0, left)), Anchor::RightMargin | Anchor::OutsideMargin => Some((s.page_w - s.margin_right, s.margin_right)), _ => None, } }; let page_y = |s: &SectionProps| match float.v_rel { Anchor::Page => Some((0.0, s.page_h)), Anchor::Margin => Some((s.margin_top, s.page_h - s.margin_top - s.margin_bottom)), Anchor::TopMargin | Anchor::InsideMargin => Some((0.0, s.margin_top)), Anchor::BottomMargin | Anchor::OutsideMargin => Some((s.page_h - s.margin_bottom, s.margin_bottom)), _ => None, }; let hx = frame.and_then(|f| page_x(f.sect).map(|(a, e)| (a - f.origin.0, e))).unwrap_or(col); let vy = frame.and_then(|f| page_y(f.sect).map(|(a, e)| (a - f.origin.1, e))).unwrap_or((y0, 0.0)); Rect::new(align_in(hx, float.x, w, float.h_align), align_in(vy, float.y, h, float.v_align), w, h) } /// Where an object of `size` starts on one axis of `area` (start, extent): at `offset` from the /// area's start, or aligned in it when `align` is set. fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option) -> f32 { match align { None => start + if offset.is_finite() { offset.clamp(-31_680.0, 31_680.0) } else { 0.0 }, Some(FloatAlign::Start | FloatAlign::Inside) => start, Some(FloatAlign::Center) => start + (extent - size) / 2.0, Some(FloatAlign::End | FloatAlign::Outside) => start + extent - size, } } /// An area text keeps clear of: its bounds, whether text may only go above and below it, and /// (Tight and Through wrapping) the outline text follows inside it, and the sides text may go on. type WrapArea = (Rect, bool, Option>, WrapText); /// The area text keeps clear of around a floating object at `r` (its rectangle grown by the /// distances from text; Tight and Through: around `outline`, unit coordinates across `r`, when /// it has one), and whether text may only go above and below it. `None` when text flows over /// or under the object. fn wrap_area(r: Rect, float: &Float, outline: Option<&[(f32, f32)]>) -> Option { if matches!(float.wrap, Wrap::Inline | Wrap::BehindText | Wrap::InFrontOfText) { return None; } if matches!(float.wrap, Wrap::Tight | Wrap::Through) && let Some((area, c)) = outline.and_then(|o| contour::contour_area(r, float, o)) { return Some((area, false, Some(Arc::new(c)), float.wrap_text)); } let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }; let (side, top, bottom) = (d(float.dist), d(float.dist_top), d(float.dist_bottom)); // A rotated object keeps text clear of its rotated bounds. let r = float.spin().bounds(r); Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text)) } /// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`. fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec { excl.iter() .map(|(r, tb, c, side)| para::Exclusion { top: r.y - y0, bottom: r.bottom() - y0, left: r.x - x0, right: r.right() - x0, top_bottom: *tb, contour: c.clone(), side: *side, }) .filter(|e| e.bottom > 0.0) .collect() } /// The items drawing floating object `o` at `rect` (a group: its members), turned by its own /// spin inside `outer` (its group's, about `outer`'s centre point). fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryRef, path: &[u32], off: usize) -> Vec { let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(); let (ospin, oc) = outer; let rect = carried(rect, ospin, oc); let spin = own.within(ospin); match o { InlineObject::Image { media, crop, .. } => { vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off, spin }] } InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => { vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects, freeform: freeform.clone(), spin, extra: extra.clone(), }] } InlineObject::Graphic { graphic, .. } => { vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off, spin }] } InlineObject::Group { .. } => { let centre = Point::new(rect.x + rect.w / 2.0, rect.y + rect.h / 2.0); match o.canvas_style() { // A Drawing Canvas: its background and outline, then its members clipped to its // frame, all turned together: the members sit in the unturned frame and the clip // carries the turn (drawn as the turn around the clip). Some(c) => { let members = group_members(o, rect) .into_iter() .flat_map(|(r, m)| float_items(m, r, (Spin::default(), centre), story, path, off)) .collect(); vec![ Placed::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, fill: c.fill, stroke: c.stroke, stroke_width: c.stroke_width, effects: Default::default(), freeform: None, spin, extra: Default::default(), }, Placed::Clip { rect, spin, items: members }, ] } None => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, (spin, centre), story, path, off)).collect(), } } _ => Vec::new(), } } /// A group's members, each with its rectangle when the group is at `rect`. /// `rect` moved (not turned) to where `spin` about `centre` takes its centre: a group member's /// frame inside its turned group. fn carried(rect: Rect, spin: Spin, centre: Point) -> Rect { let (cx, cy) = (rect.x + rect.w / 2.0, rect.y + rect.h / 2.0); let (nx, ny) = spin.apply(centre.x, centre.y, cx, cy); Rect::new(rect.x + nx - cx, rect.y + ny - cy, rect.w, rect.h) } fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> { o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect() } /// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in /// the current column, add their wrap areas to the page (replacing any from an earlier attempt, /// from `excl_mark` on) and lay the paragraph out around them, with its list `label`. fn anchor_floats( ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, y0: f32, excl_mark: usize, label: &Option<(String, Level)>, ) -> (HashMap, Arc) { pb.excl.truncate(excl_mark); ctx.fields.page = pb.number; let (col_x, width) = (pb.col_x(), pb.col_w()); let mut rects = HashMap::new(); if !pb.web { let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0) }; // An object left out of the layout (hidden, or deleted in the final text) is not placed, so // it takes no room either. let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted); for (oi, o) in p.objects.iter().enumerate() { let Some((w, h, float)) = floating(o) else { continue }; if left_out(oi) { continue; } let r = float_rect(Some(frame), (col_x, width), y0, w, h, float); rects.insert(oi, r); let outline = ctx.wrap_outline(o); if let Some(area) = wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())) { pb.excl.push(area); } } } let rel = rel_exclusions(&pb.excl, col_x, y0); (rects, ctx.para_labelled(p, width, None, &rel, label.clone())) } /// Where a paragraph's floating objects are positioned from, when they aren't placed already: /// the box on its page, the column (left, width) and the paragraph's top. Also where offsets /// relative to the column and paragraph start, for dragging. #[derive(Clone, Copy)] struct ObjFrame<'a> { page: Option>, col: (f32, f32), para_y: f32, /// A table's character formatting under the paragraph's (see `left_out_objects`). table_chr: Option<&'a CharProps>, } /// The pictures, shapes and text boxes in lines `l0..l1` of paragraph `p` (story `story`, at /// `path`), whose lines start at (x, y): floating ones drawn, every one's area (hit testing, /// selection) and text boxes' text. Floating rects come from `floats` (by object index) or are /// worked out from `at`. Returns what goes behind the text, and what goes in front. #[allow(clippy::too_many_arguments)] fn place_objects( ctx: &mut Ctx, story: StoryRef, path: &[u32], p: &Paragraph, pl: &ParaLayout, (l0, l1): (usize, usize), (x, y): (f32, f32), at: &ObjFrame, floats: &HashMap, depth: usize, ) -> (Vec, Vec) { let (mut behind, mut front) = (Vec::new(), Vec::new()); let Some(fl) = pl.lines.get(l0) else { return (behind, front) }; // Objects left out of the layout (hidden, or deleted in the final text) aren't placed. let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted); for li in l0..l1 { let Some(line) = pl.lines.get(li) else { continue }; for k in line.c0..line.c1 { let Some(c) = pl.clusters.get(k) else { continue }; let para::ClKind::Object(oi) = c.kind else { continue }; if left_out(oi) { continue; } let Some(obj) = p.objects.get(oi) else { continue }; let Some((w, h, float)) = obj.frame() else { continue }; let floating = float.wrap != Wrap::Inline; let rect = if floating { floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float)) } else { let cx = x + line.cl_left(k).unwrap_or(0.0); display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h) }; let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front }; // Floating ones are drawn here; inline ones with their line. if floating { layer.extend(float_items(obj, rect, (Spin::default(), Point::default()), story, path, c.start)); } let text_box = match obj { InlineObject::Shape { story: Some(id), .. } if ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox) => { Some(*id) } _ => None, }; // A box linked from another shows (and edits) that box's story. let shown = text_box.map(|id| textbox::shown_story(ctx, id)); front.push(Placed::Object { rect, spin: float.spin(), story, path: Path(path.to_vec()), off: c.start, text_box: shown, wrap: float.wrap, origin: Point::new(at.col.0, at.para_y), }); // Its text (a group: its text boxes', each where the group's turn carries it; the // text itself stays upright), unless the box budget says no (a box inside itself, // too deep, too many). let centre = Point::new(rect.x + rect.w / 2.0, rect.y + rect.h / 2.0); let boxes: Vec<(u32, Rect)> = match obj { InlineObject::Group { .. } => group_members(obj, rect) .into_iter() .filter_map(|(r, c)| { c.text_box() .filter(|id| ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)) .map(|id| (id, carried(r, float.spin(), centre))) }) .collect(), _ => text_box.map(|id| (id, rect)).into_iter().collect(), }; for (id, rect) in boxes { let items = textbox::box_items(ctx, id, rect, depth); let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front }; layer.extend(items); } } } (behind, front) } /// Footnotes referenced in a paragraph laid out as `pl`: in its text, and inside its text boxes /// (on the box's cluster), as (cluster, note id). fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32)> { let mut notes = pl.notes.clone(); if p.objects.iter().any(|o| !o.text_boxes().is_empty()) { for (ci, c) in pl.clusters.iter().enumerate() { if let para::ClKind::Object(oi) = c.kind && let Some(o) = p.objects.get(oi) { for id in o.text_boxes() { notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n))); } } } } notes } fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) { let width = pb.col_w(); // Floating objects anchored here join the page's wrap areas before the text is laid out; if // the paragraph then starts somewhere else (another column or page, or contextual spacing // drops its space before), they are placed again there. let space_before = ctx.doc.styles.resolve_para(&p.props).space_before; let excl_mark = pb.excl.len(); let guess = pb.y + space_before; let label = ctx.next_label(p); let (mut float_rects, mut pl) = anchor_floats(ctx, pb, p, guess, excl_mark, &label); // The anchor paragraph's top the floats were placed from. let mut anchor_y = guess; let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col); let placed_at = spot(pb); if pl.rp.page_break_before && !pb.at_top() && !pb.web { pb.new_page(block, body_top); } // Contextual spacing: no space between paragraphs of the same style when either asks for it. let mut before = pl.rp.space_before; if let Some((style, ctxl, after)) = pb.prev.take() && style == pl.rp.style && !pb.at_top() { if ctxl { pb.y -= after; } if pl.rp.contextual_spacing { before = 0.0; } } pb.y += before; // Keep lines together: if it doesn't fit but would on an empty column, move it. if pl.rp.keep_lines || pl.rp.keep_next { let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top { pb.advance(block, body_top); pb.y += pl.rp.space_before.min(0.0); } } // A different top only matters when there is something to wrap around. if spot(pb) != placed_at || ((pb.y - guess).abs() > 0.01 && !pb.excl.is_empty()) { let y0 = pb.y; (float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label); anchor_y = y0; } let mut n = pl.lines.len(); let mut l0 = 0; while l0 < n { // How many lines fit? let Some(first) = pl.lines.get(l0) else { break }; // A paragraph's first line may start below its top (pushed down by a floating object). let lead = if l0 == 0 { first.top.max(0.0) } else { 0.0 }; let mut l1 = l0; let mut forced_break = None; let mut new_notes: Vec = Vec::new(); let notes = para_notes(ctx.doc, p, &pl); while l1 < n { let Some(l) = pl.lines.get(l1) else { break }; // Footnotes referenced on this line go to the bottom of this page. let mut line_notes = Vec::new(); if !pb.web { for (ci, id) in ¬es { if *ci >= l.c0 && *ci < l.c1 && !pb.notes.iter().chain(new_notes.iter()).any(|x| x.id == *id) && let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) { let blocks = part.blocks.clone(); let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0, None); line_notes.push(NoteBox { id: *id, items, h, cont: false }); } } } let extra: f32 = line_notes.iter().map(|n| n.h).sum::() + if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 }; let limit = pb.bottom - new_notes.iter().map(|n| n.h).sum::() - if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 } - extra; let bottom = pb.y + lead + (l.top + l.height - first.top); // Notes too long for the rest of the page continue on the next one: the line still // fits if the notes before its last one, and that one's first line, fit below it. // The notes then take the rest of the page. let split = bottom > limit + 0.01 && line_notes.split_last().is_some_and(|(last, others)| { let before: f32 = pb.notes.iter().chain(&new_notes).chain(others).map(|n| n.h).sum(); let sep = pb.seps.sep_h(pb.notes.first().is_some_and(|n| n.cont)); pb.body_floor().max(bottom) + sep + before + last.first_piece() + pb.seps.notice_h() <= pb.orig_bottom + 0.01 }); if bottom > limit + 0.01 && l1 > l0 && !split { break; } // A line holding nothing but a page or column break stays at the bottom of the page // even when it doesn't fit: it only ends the page, and moving it would leave a blank // page behind it (Word does the same). let only_break = matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && p.text .get(l.start..l.stop) .is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK))); if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break { break; } new_notes.extend(line_notes); l1 += 1; if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web { forced_break = Some(l.end); break; } if split { break; } } // Widow/orphan control (2-line minimum at either end). if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 { if l1 - l0 == 1 && l0 == 0 && !pb.at_top() { l1 = l0; // orphan: move the first line to the next page } else if n - l1 == 1 && l1 - l0 >= 3 { l1 -= 1; // widow: take one more line along } } // A row (text both sides of a floating object) stays together. while l1 > l0 && l1 < n && pl.lines.get(l1).is_some_and(|l| l.beside) { l1 -= 1; } if l1 == l0 { if pb.at_top() { l1 = l0 + 1; // can't fit even one line on an empty page: overflow } else { pb.advance(block, body_top); if l0 == 0 { let y0 = pb.y; (float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label); anchor_y = y0; n = pl.lines.len(); } continue; } } // Keep only the notes of the lines that are placed. let placed_end = pl.lines.get(l1.saturating_sub(1)).map(|l| l.c1).unwrap_or(0); new_notes.retain(|n| notes.iter().any(|(ci, nid)| *nid == n.id && *ci < placed_end)); let before = pb.notes_h(); pb.notes.extend(new_notes); if pb.balance.is_some() { // Balanced columns end where the balance puts them unless the notes reach higher. pb.cap_columns(); } else { // Relative, so a bottom moved down for notes continuing on the next page stays put. pb.bottom -= pb.notes_h() - before; } // Mirror indents: on an even (left-hand) page the inside indent is on the right, so the // paragraph's lines (broken for the same width) move over by the indents' difference. let x = pb.col_x() + mirror_shift(&pl.rp, pb.number, pb.web); let y = pb.y + lead; // Notes that no longer fit below the text (a long one, or the rest of one continued from // the page before) get what's left under it and continue on the next page. if !pb.notes.is_empty() && !pb.web && let Some(placed_bottom) = item_bottom(y, &pl, l0, l1) && placed_bottom > pb.bottom + 0.01 { pb.bottom = pb.body_floor().max(placed_bottom).min(pb.orig_bottom); } let mut items = Vec::new(); push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width); // Pictures, shapes and text boxes in these lines (web view: floats placed by this piece). let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }), col: (x, width), para_y: if pb.web { y } else { anchor_y }, table_chr: None, }; let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], p, &pl, (l0, l1), (x, y), &at, &float_rects, 0); items.extend(front); // Line numbers in the left margin. if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers) && let Some(first) = pl.lines.get(l0) { for li in l0..l1 { let Some(line) = pl.lines.get(li).filter(|l| !l.beside) else { continue }; pb.line_no = pb.line_no.saturating_add(1); let n = pb.line_no.saturating_add(ln.start.saturating_sub(1)); if ln.count_by > 1 && !n.is_multiple_of(ln.count_by) { continue; } let num = ctx.number_para(n); let w = num.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(10.0); let dist = if ln.distance > 0.0 { ln.distance } else { 18.0 }; let ly = y + (line.top - first.top) + (line.baseline - line.top) - num.lines.first().map(|l| l.baseline).unwrap_or(10.0); items.push(Placed::Lines { story: DECOR_STORY, path: Path(vec![]), para: num.clone(), l0: 0, l1: 1, x: pb.sect.margin_left + pb.sect.gutter - dist - w, y: ly, turn: TextDirection::Horizontal, }); } } let n_behind = behind.len(); if let Some(pg) = pb.pages.last_mut() { for (i, it) in behind.into_iter().enumerate() { pg.items.insert(i.min(pg.items.len()), it); } pg.items.extend(items); } if let Some(b) = pb.behind.last_mut() { *b += n_behind; } if let (Some(f), Some(l)) = (pl.lines.get(l0), pl.lines.get(l1 - 1)) { pb.y = y + (l.top + l.height - f.top); pb.content_bottom = pb.content_bottom.max(pb.y); } l0 = l1; match forced_break { Some(LineEnd::PageBreak) => pb.new_page(block, body_top), Some(LineEnd::ColumnBreak) => { pb.col_break_page = Some(pb.pages.len().saturating_sub(1)); pb.advance(block, body_top) } _ if l0 < n => pb.advance(block, body_top), _ => {} } } pb.y += pl.rp.space_after; pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after)); } /// The outline of the body for an Outline view pass. struct OutlinePass { view: outline::OutlineView, levels: Vec, hidden: Vec, } impl OutlinePass { fn new(doc: &Document, view: outline::OutlineView) -> Self { let levels = outline::levels(doc); let hidden = outline::hidden(&levels, &view); OutlinePass { view, levels, hidden } } } /// A body paragraph in Outline view (one long sheet: no page or column breaks, no floats /// wrapping): indented by its level behind its symbol; hidden when collapsed or deeper than Show /// Level; body text cut to its first line with Show First Line Only. fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, o: &OutlinePass) { // List numbers count the hidden paragraphs too. let label = ctx.next_label(p); if o.hidden.get(block).copied().unwrap_or(false) { return; } let level = o.levels.get(block).copied().unwrap_or(outline::BODY); let indent = outline::indent(&o.levels, block); let x = pb.col_x() + indent + outline::SYMBOL_W; let width = (pb.col_w() - indent - outline::SYMBOL_W).max(72.0); let q = outline::outline_para(ctx.doc, p, o.view.plain); ctx.fields.page = pb.number; let pl = ctx.para_labelled(&q, width, None, &[], label); pb.y += pl.rp.space_before.max(0.0); let n = pl.lines.len(); let l1 = if o.view.first_line_only && level >= outline::BODY { n.min(1) } else { n }; let y = pb.y + pl.lines.first().map(|f| f.top.max(0.0)).unwrap_or(0.0); let mut items = Vec::new(); push_para(&mut items, StoryRef::Body, &[block as u32], &pl, 0, l1, x, y, width); let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }), col: (x, width), para_y: y, table_chr: None }; let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], &q, &pl, (0, l1), (x, y), &at, &HashMap::new(), 0); items.extend(front); let children = level < outline::BODY && outline::has_children(&o.levels, block); // Collapsed, or with something under it hidden by Show Level: the wavy line says so. let collapsed = children && o.hidden.get(block + 1).copied().unwrap_or(false); items.extend(outline::symbol_items(&pl, x, y, level, children, collapsed)); if let Some(pg) = pb.page() { for (i, it) in behind.into_iter().enumerate() { pg.items.insert(i.min(pg.items.len()), it); } pg.items.extend(items); } if let (Some(f), Some(l)) = (pl.lines.first(), l1.checked_sub(1).and_then(|k| pl.lines.get(k))) { pb.y = y + (l.top + l.height - f.top); } pb.y += pl.rp.space_after.max(0.0); } /// How far a paragraph with mirrored indents (`w:mirrorIndents`) moves right on page `number`: /// its left and right indents are the inside and outside ones, which swap sides on even pages. fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: bool) -> f32 { if !rp.mirror_indents || web || !number.is_multiple_of(2) { return 0.0; } let (left, right) = rp.visual_indents(); right - left } /// Height of the first line of the block after `block` (for keep-with-next). fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { match ctx.doc.body.get(block + 1).map(|b| &**b) { Some(Block::Para(p)) => { // Don't advance list counters for a lookahead: lay out without the label. let rp = ctx.doc.styles.resolve_para(&p.props); let env = para::ParaEnv { doc: ctx.doc, width, label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, field_codes: ctx.opts.field_codes, hide_deleted: ctx.opts.hides_deleted(StoryRef::Body), table: None, proofing: None, exclusions: &[], eq_number: ctx.eq_count, grid: ctx.grid, }; let _ = rp; let pl = para::layout_para(p, &env); pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0) } Some(Block::Table(_)) => 24.0, None => 0.0, } } fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { pb.prev = None; let width = pb.col_w(); let mut tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1); if let Some(f) = t.props.float.filter(|_| !pb.web) { // Before Word 2013 layout (compatibility mode 15) an offset places the first cell's text, // so the edge sits a cell margin further out. let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 }; let h: f32 = tl.rows.iter().map(|r| r.height).sum(); if h <= pb.uncapped_bottom() - pb.top + 0.01 { place_floating_table(pb, &tl, &f, legacy, block, body_top); return; } // Too tall for a page: Word lets it run across pages like a table in the text, from its // own left edge. tl.x = float_table_x(pb, tl.width, &f, legacy) - pb.col_x(); } // A table in the text flow starts below the floating objects in its way. below_floats(pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width); let header_rows: Vec = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect(); let place = |pb: &mut PageBuilder, row: &table::RowLayout| { let (x, y) = (pb.col_x() + tl.x, pb.y); if let Some(pg) = pb.page() { for it in &row.items { let mut it = it.clone(); it.translate(x, y); pg.items.push(it); } } pb.y += row.height; pb.content_bottom = pb.content_bottom.max(pb.y); }; for (ri, row) in tl.rows.iter().enumerate() { let splittable = !header_rows.contains(&ri) && !t.rows.get(ri).is_some_and(|r| r.props.cant_split); let mut rest: Option = None; // On a new page holding only the repeated header rows. let mut fresh = false; // Each pass places the part of the row that fits, then breaks the page. for _ in 0..1000 { let cur = rest.as_ref().unwrap_or(row); if pb.y + cur.height <= pb.bottom + 0.01 { break; } let split = if splittable { table::split_row(cur, pb.bottom - pb.y) } else { None }; match split { Some((a, b)) => { place(pb, &a); table_notes(ctx, pb, &a.items); rest = Some(b); } // Like Word, a row that does not fit on an empty page, or under the header rows // repeated there, is placed anyway and runs into the bottom margin. None if pb.at_top() || fresh => break, None => {} } pb.advance(block, body_top); // Repeat header rows. if !header_rows.contains(&ri) { for hr in &header_rows { if let Some(h) = tl.rows.get(*hr) { place(pb, h); } } } fresh = true; } let last = rest.as_ref().unwrap_or(row); place(pb, last); table_notes(ctx, pb, &last.items); } } /// Footnotes referenced in the cell lines of a placed table row (or part of one) go to the /// bottom of the page, like those of body paragraphs; notes that don't fit there continue on /// the next page (see [`PageBuilder::flush_notes`]). fn table_notes(ctx: &mut Ctx, pb: &mut PageBuilder, items: &[Placed]) { if pb.web { return; } let mut ids: Vec = Vec::new(); for it in items { let Placed::Lines { story: StoryRef::Body, para, l0, l1, .. } = it else { continue }; let (Some(a), Some(b)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) else { continue }; for (ci, id) in ¶.notes { if *ci >= a.c0 && *ci < b.c1 && !ids.contains(id) && !pb.notes.iter().any(|n| n.id == *id) { ids.push(*id); } } } let before = pb.notes_h(); for id in ids { let Some(part) = ctx.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) else { continue }; let blocks = part.blocks.clone(); let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.sect.text_width(), None, 0, None); pb.notes.push(NoteBox { id, items, h, cont: false }); } pb.bottom -= pb.notes_h() - before; } /// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text /// flow; the text after it wraps around it. fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) { let h: f32 = tl.rows.iter().map(|r| r.height).sum::().clamp(0.0, 100_000.0); let w = tl.width.clamp(1.0, 100_000.0); let [dl, dt, dr, db] = f.dist_from_text(); for attempt in 0..2 { let s = pb.sect; let h_area = float_table_h_area(pb, f); // The text a table anchored to it stands in: where the next paragraph starts. let v_area = match f.v_rel { Anchor::Page => (0.0, s.page_h), Anchor::Margin => (s.margin_top, s.text_height()), _ => (pb.y, 0.0), }; let x = float_table_x(pb, w, f, legacy); let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &pb.float_tables); // Like Word, a table that doesn't fit below the text before it moves to the next page with // its anchor: one placed relative to the paragraph when it would run off the page, one // placed on the page or margin when it would also cover the text already on the page. let moves = if f.v_rel == Anchor::Paragraph { r.bottom() > pb.bottom + 0.01 } else { let covers_text = r.y < pb.y - 0.01 && r.bottom() > pb.top + 0.01 && r.x < pb.col_x() + pb.col_w() && r.right() > pb.col_x(); covers_text && pb.y + h > pb.bottom + 0.01 }; if attempt == 0 && moves && !pb.at_top() { pb.advance(block, body_top); continue; } if let Some(pg) = pb.page() { let mut y = r.y; for row in &tl.rows { for it in &row.items { let mut it = it.clone(); it.translate(r.x, y); pg.items.push(it); } y += row.height; } } let area = r.inset(-dl, -dt, -dr, -db); pb.excl.push((area, false, None, WrapText::BothSides)); pb.float_tables.push(area); return; } } /// The area a floating table's `x` is measured in: its start and width on the page. fn float_table_h_area(pb: &PageBuilder, f: &TableFloat) -> (f32, f32) { let s = pb.sect; match f.h_rel { Anchor::Page => (0.0, s.page_w), Anchor::Margin => (s.margin_left + s.gutter, s.text_width()), _ => (pb.col_x(), pb.col_w()), } } /// A floating table's left edge on the page (`legacy`: the first cell's left margin before Word /// 2013 layout, when an offset places the cell's text). fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32) -> f32 { let x = align_in(float_table_h_area(pb, f), f.x, w, f.h_align); if f.h_align.is_none() { x - legacy } else { x } } /// Move `pb.y` below the floating objects in the way of something spanning `x0..x1` that /// starts there (a table in the text flow doesn't wrap around them). fn below_floats(pb: &mut PageBuilder, x0: f32, x1: f32) { pb.y = below(&pb.excl, pb.y, x0, x1); } /// `y`, moved below the wrap areas in `excl` in the way of something spanning `x0..x1` that /// starts there. fn below(excl: &[WrapArea], mut y: f32, x0: f32, x1: f32) -> f32 { for _ in 0..64 { let in_the_way = excl.iter().filter(|(r, ..)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0); let Some(bottom) = in_the_way.map(|(r, ..)| r.bottom()).reduce(f32::max) else { break }; y = bottom; } y } /// Section `si`'s headers (or footers, as `get` picks), with those it leaves out inherited from /// the sections before it (Word's "link to previous"). fn linked(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet) -> HeaderSet { let mut set = sections.get(si).map(|(_, s)| get(s)).unwrap_or_default(); for (_, s) in sections.iter().take(si).rev() { if set.default.is_some() && set.first.is_some() && set.even.is_some() { break; } let o = get(s); set.default = set.default.or(o.default); set.first = set.first.or(o.first); set.even = set.even.or(o.even); } set } fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) { let total = pages.len() as u32; // Pages per section. let mut per_section: HashMap = HashMap::new(); for p in pages.iter() { *per_section.entry(p.section).or_default() += 1; } let even_odd = ctx.doc.settings.even_odd_headers; let mut first_of_section: HashSet = HashSet::new(); let mut seen = HashSet::new(); for (i, p) in pages.iter().enumerate() { if seen.insert(p.section) { first_of_section.insert(i); } } for (i, page) in pages.iter_mut().enumerate() { let Some((_, sect)) = sections.get(page.section) else { continue }; // Inherit header/footer references from earlier sections (Word's "link to previous"). let pick = |get: fn(&SectionProps) -> HeaderSet| -> Option { let set = linked(sections, page.section, get); if sect.title_page && first_of_section.contains(&i) { return set.first; } if even_odd && page.number % 2 == 0 { return set.even.or(set.default); } set.default }; let hid = pick(|s| s.headers); let fid = pick(|s| s.footers); ctx.fields.page = page.number; ctx.fields.page_format = sect.page_num_format; ctx.fields.pages = total; ctx.fields.section_pages = per_section.get(&page.section).copied().unwrap_or(1); ctx.fields.section = page.section as u32 + 1; let w = sect.text_width(); let x = sect.margin_left + sect.gutter; if let Some(id) = hid && let Some(part) = ctx.doc.parts.get(&id) { let blocks = part.blocks.clone(); let frame = PageFrame { sect, origin: (x, sect.header) }; let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items; for it in &mut items { it.translate(x, sect.header); } page.header = items; page.header_story = Some(id); } if let Some(id) = fid && let Some(part) = ctx.doc.parts.get(&id) { let blocks = part.blocks.clone(); // The footer's height fixes where it sits, which page-relative floats need. let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, None).height; let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h) }; let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items; for it in &mut items { it.translate(x, sect.page_h - sect.footer - h); } page.footer = items; page.footer_story = Some(id); } } } /// Mirrored margins: even pages have the inside margin (and gutter) on the right, so their /// header, text, notes and footer move to start at the outside margin. Page borders, measured /// from the page edge, stay. fn mirror_even_pages(pages: &mut [Page], sections: &[(usize, &SectionProps)]) { for page in pages.iter_mut().filter(|p| p.number % 2 == 0) { let Some((_, s)) = sections.get(page.section) else { continue }; let dx = s.margin_right - s.margin_left - s.gutter; if dx.abs() < 0.001 || !dx.is_finite() { continue; } let borders = page_border_rules(s).len(); for it in page.items.iter_mut().skip(borders).chain(page.header.iter_mut()).chain(page.footer.iter_mut()) { it.translate(dx, 0.0); } page.body.x += dx; } } /// Is an object floating (not laid out inline)? pub fn is_floating(o: &InlineObject) -> bool { o.is_floating() } /// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web). pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { static T0: std::sync::OnceLock = std::sync::OnceLock::new(); T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 } // `Instant::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. #[cfg(target_arch = "wasm32")] { let ms = js_sys::Date::now(); if ms.is_finite() { ms } else { 0.0 } } } #[cfg(test)] mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] mod tests_complex; #[cfg(test)] mod tests_grid; #[cfg(test)] mod tests_pages; #[cfg(test)] mod tests_textbox; #[cfg(test)] mod tests_vertical;