Files
wordcraft/crates/layout/src/lib.rs
T
Valentin KleinandClaude Opus 5.5 4e710a920b Footnote and Endnote options per section (#385) (#390)
- Model: NoteProps (pos, numFmt, numStart, numRestart) in the settings and
  each section; Document::note_options resolves section > document > defaults.
- DOCX: w:footnotePr / w:endnotePr read and written in settings and every
  w:sectPr in schema order; hostile numStart clamped (ECMA-376 §17.11).
- Layout: footnotes below the text, endnotes at the end of each section,
  numbering restarted per section or per page (from where notes land, at
  most three passes), per-section number formats.
- Commands: references.noteOptions takes kind/pos/numFmt/numStart/
  numRestart/scope; references.convertNotes (footnote/endnote/swap, one
  undo step); references.footnote/endnote take a custom mark.
- UI: Footnote and Endnote dialog from the Footnotes group launcher;
  strings in every catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:10:17 -04:00

2631 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)]
mod contour;
pub mod display;
pub mod fields;
pub mod hit;
pub mod kinsoku;
pub mod math;
pub mod para;
mod table;
mod textbox;
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::{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/Outline: like web, page breaks shown as rules.
Draft,
}
#[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,
/// Check spelling and grammar (squiggles).
pub proofing: 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,
}
/// 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<ParaLayout>,
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<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
/// A freeform's paths (ink strokes among them).
freeform: Option<Arc<wordcraft_doc::freeform::Freeform>>,
effects: wordcraft_doc::effects::ShapeEffects,
spin: Spin,
},
/// 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<wordcraft_doc::graphic::Graphic>,
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<Placed>,
},
/// A table cell's area (for hit testing and cell selection).
Cell {
rect: Rect,
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<u32>,
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<Rect> {
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<Placed>,
/// Header / footer items (drawn dimmed while editing the body).
pub header: Vec<Placed>,
pub footer: Vec<Placed>,
/// The body text area (margins).
pub body: Rect,
pub header_story: Option<u32>,
pub footer_story: Option<u32>,
/// Top-level body block index at the start of the page.
pub first_block: usize,
}
/// The whole layout.
#[derive(Clone, Debug, Default)]
pub struct DocLayout {
pub pages: Vec<Page>,
/// 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<String>,
page: Option<(u32, u32, u32, u32)>,
table: u64,
notes: u64,
excl: u64,
eq: u32,
}
/// Memoised paragraph layouts.
#[derive(Default)]
pub struct LayoutCache {
paras: HashMap<Key, Arc<ParaLayout>>,
/// 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<Arc<Vec<(f32, f32)>>>>,
env: u64,
used: HashSet<Key>,
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: Hash>(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();
hash_of(&(
s,
opts.show_hidden,
opts.hide_deleted,
opts.proofing,
opts.field_codes,
wordcraft_proof::user_dictionary().len(),
doc.settings.auto_hyphenation,
))
}
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,
})
}
/// 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<u32, Arc<ParaLayout>>,
/// 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,
}
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<Arc<Vec<(f32, f32)>>> {
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<ParaLayout> {
if let Some(p) = self.numbers.get(&n) {
return p.clone();
}
let mut p = Paragraph::with_text(
&n.to_string(),
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
);
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
let env = para::ParaEnv {
doc: self.doc,
width: 60.0,
label: None,
fields: &self.fields,
show_hidden: false,
field_codes: false,
hide_deleted: false,
table: None,
proofing: false,
exclusions: &[],
eq_number: 0,
};
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<&para::CellText>,
exclusions: &[para::Exclusion],
label: Option<(String, Level)>,
) -> Arc<ParaLayout> {
let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None };
// 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 },
};
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.opts.hide_deleted,
table,
proofing: self.opts.proofing,
exclusions,
eq_number,
};
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<u32, usize>) -> HashMap<u32, fields::NoteNum> {
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<usize>)>; 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<bool>)> = 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<u32, u32> {
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<u32, usize> {
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<&para::CellText>,
depth: usize,
frame: Option<PageFrame>,
) -> BoxLayout {
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<WrapArea> = Vec::new();
// Areas of the box's floating tables, which other floating tables may have to avoid.
let mut float_tables: Vec<Rect> = 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.opts.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);
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<Placed>,
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<PageFrame>,
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::<f32>().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 internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom).
const BOX_INSET_X: f32 = 7.2;
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<Placed>, height: f32) -> Vec<Placed> {
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<Placed>, story: StoryRef, path: &[u32], pl: &Arc<ParaLayout>, 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<Border>| 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<Page>,
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<NoteBox>,
/// The parts of footnotes that didn't fit on their page, continued on the next one.
carry: Vec<NoteBox>,
/// 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<WrapArea>,
/// Wrap areas of the floating tables on this page, which others may not overlap.
float_tables: Vec<Rect>,
/// 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<usize>,
/// 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<usize>,
/// 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,
}
/// 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<Placed>, f32);
/// The document's own footnote or endnote separators and continuation notice, laid out (`None`:
/// the defaults).
#[derive(Clone, Default)]
struct NoteSeps {
sep: Option<SepBox>,
cont: Option<SepBox>,
notice: Option<SepBox>,
}
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>| 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<Placed> {
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<Placed>,
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<f32> {
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 })
}
}
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::<f32>(),
}
}
/// 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) };
let body = Rect::new(s.margin_left + s.gutter, body_top, s.text_width(), (s.page_h - s.margin_bottom - body_top).max(36.0));
let mut decor = Vec::new();
if !self.web {
// Page borders, measured from the page edge (Word's default), so they surround the
// header, footer and line numbers.
if let Some(b) = &s.page_borders {
let sp = |e: &Option<Border>| 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) {
decor.push(Placed::Rule { x0: a.0, y0: a.1, x1: bb.0, y1: bb.1, border: e });
}
}
}
// 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: s.page_h - s.margin_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 { s.page_h - s.margin_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();
let mut pages = HashMap::new();
let mut l = layout_pass(doc, cache, opts, &pages);
let per_page = opts.view == ViewMode::Print
&& doc.sections().iter().any(|(_, s)| doc.note_options(s, false).num_restart == wordcraft_doc::section::NoteRestart::EachPage);
if per_page {
// Number each page's footnotes from where the pass before placed them, until they stay.
for _ in 1..MAX_NOTE_PASSES {
let placed = footnote_pages(doc, &l.pages);
if placed == pages {
break;
}
pages = placed;
l = layout_pass(doc, cache, opts, &pages);
}
}
l.ms = now_ms() - t0;
l
}
/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]).
fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, note_pages: &HashMap<u32, usize>) -> DocLayout {
let t0 = now_ms();
let env = env_hash(doc, opts);
if env != cache.env {
cache.paras.clear();
cache.env = env;
}
cache.used.clear();
let notes = note_info(doc, opts.hide_deleted, 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,
counters: Counters::default(),
fields: FieldCtx {
page: 1,
pages: 1,
section_pages: 1,
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(),
};
let sections = doc.sections();
let web = opts.view != ViewMode::Print;
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,
};
// 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;
for (si, (end, sect)) in sections.iter().enumerate() {
let sect: &SectionProps = if web { sect_ref } else { sect };
pb.sect = sect;
pb.sect_idx = si;
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());
}
let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, sect.headers.default) };
let first_top = if web || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, sect.headers.first) };
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 {
if let Some(n) = restart {
pb.number = n.saturating_sub(1);
}
pb.new_page(block, first_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;
}
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 {
Block::Para(p) => 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<u32> = 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;
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;
}
if !web {
headers_footers(&mut ctx, &mut pages, &sections);
}
// 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));
}
}
}
DocLayout { pages, index, ms: now_ms() - t0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) }
}
/// 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<u32>, 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;
}
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);
if pb.y + h > pb.bottom && !pb.at_top() {
pb.advance(block, pb.top);
}
let (x, y) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
for mut it in items {
it.translate(x, y);
pg.items.push(it);
}
}
pb.y += h;
}
}
fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
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<u32>) -> 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 h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height;
// Word starts the body right below a header that reaches past the top margin, no gap.
sect.margin_top.max(sect.header + 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<PageFrame>, 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<FloatAlign>) -> 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<Arc<contour::Contour>>, 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<WrapArea> {
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<para::Exclusion> {
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<Placed> {
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, .. } => {
vec![Placed::Shape {
rect,
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
effects: *effects,
freeform: freeform.clone(),
spin,
}]
}
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,
},
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<usize, Rect>, Arc<ParaLayout>) {
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.opts.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<PageFrame<'a>>,
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<usize, Rect>,
depth: usize,
) -> (Vec<Placed>, Vec<Placed>) {
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.opts.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<NoteBox> = 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 &notes {
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::<f32>()
+ 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::<f32>()
- 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;
}
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split {
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;
}
let x = pb.col_x();
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" && p.props.suppress_line_numbers != Some(true))
&& 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));
}
/// 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.hide_deleted,
table: None,
proofing: false,
exclusions: &[],
eq_number: ctx.eq_count,
};
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<usize> = (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<table::RowLayout> = 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);
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;
}
place(pb, rest.as_ref().unwrap_or(row));
}
}
/// 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::<f32>().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);
if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph {
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
}
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<usize, u32> = 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<usize> = 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: &dyn Fn(&SectionProps) -> wordcraft_doc::section::HeaderSet| -> Option<u32> {
let mut set = get(sect);
for k in (0..page.section).rev() {
if set.default.is_some() && set.first.is_some() && set.even.is_some() {
break;
}
if let Some((_, s)) = sections.get(k) {
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);
}
}
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: &SectionProps| s.headers);
let fid = pick(&|s: &SectionProps| 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);
}
}
}
/// 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::time::Instant> = 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_textbox;