Files
wordcraft/crates/layout/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 7027853c10 R22: text boxes keep their own margins
Read lIns/tIns/rIns/bIns from a text box's wps:bodyPr (missing ones take
Word's 0.1"/0.05" defaults, hostile values clamped), lay the text out inside
them and write them back. Every text box had Word's default margins, so the
SARP form's zero-margin field labels sat 3.6 pt low.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:48:39 -04:00

3049 lines
129 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)]
pub mod balloons;
mod contour;
pub mod display;
pub mod fields;
pub mod hit;
pub mod kinsoku;
pub mod math;
pub mod outline;
pub mod para;
mod table;
mod textbox;
mod vertical;
pub mod wordart;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use wordcraft_doc::numbering::{Counters, Level};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap, WrapText};
use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat, TextDirection};
use wordcraft_doc::section::{HeaderSet, SectionProps, SectionStart};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef};
use wordcraft_geom::{Point, Rect, Spin};
pub use fields::FieldCtx;
pub use hit::VisualStep;
pub use para::{LineEnd, ParaLayout};
pub use table::{autofit_widths, measure_table_columns};
/// How the document is viewed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ViewMode {
#[default]
Print,
/// One page as wide as the window, no page breaks.
Web,
/// Draft: like web, page breaks shown as rules.
Draft,
/// Outline: like draft, the body as an outline of its headings ([`outline`]).
Outline,
}
#[derive(Clone, Debug, Default)]
pub struct LayoutOptions {
pub view: ViewMode,
/// Width of the window in Web/Draft views, points.
pub web_width: f32,
pub show_hidden: bool,
/// Final text ("No Markup"): tracked deletions take no space and draw nothing.
pub hide_deleted: bool,
/// Show revisions in balloons: tracked deletions in the body take no space and draw nothing
/// (the editor shows them in balloons beside the page). Headers, footers, notes and text
/// boxes get no balloons, so their deletions stay inline.
pub deletions_in_balloons: bool,
/// Check spelling as you type (squiggles).
pub proofing: bool,
/// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`]
/// and show the comments there in balloons joined to their anchors, as Word prints them.
/// The editor draws its own interactive balloons and leaves this off.
pub comment_balloons: bool,
/// Show field codes (`{ PAGE }`, shaded) instead of field results (View › Field Codes, Alt+F9).
/// A field whose own `code` flag is set shows the other one.
pub field_codes: bool,
/// Outline view: what's collapsed and shown (used when `view` is [`ViewMode::Outline`]).
pub outline: Option<outline::OutlineView>,
/// What the spelling check skips, and whether grammar is marked too (File › Options › Proofing).
pub proof: ProofOptions,
}
/// File › Options › Proofing choices that change the squiggles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ProofOptions {
/// Mark grammar errors as you type.
pub grammar: bool,
pub spelling: wordcraft_proof::Options,
}
impl Default for ProofOptions {
fn default() -> Self {
ProofOptions { grammar: true, spelling: wordcraft_proof::Options::default() }
}
}
impl LayoutOptions {
/// Whether tracked deletions in `story` take no space and draw nothing.
pub fn hides_deleted(&self, story: StoryRef) -> bool {
self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body)
}
}
/// Something placed on a page (page coordinates, points, y down).
#[derive(Clone, Debug)]
pub enum Placed {
/// Lines `l0..l1` of a paragraph; `y` is the top of line `l0`; `x` the column's left edge.
/// Turned lines (a table cell's text direction) run as `turn` says from the page point
/// (`x`, `y`): see [`turn_point`].
Lines {
story: StoryRef,
path: Path,
para: Arc<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,
/// Arrowheads and text warp (see [`wordcraft_doc::connector::ShapeExtra`]).
extra: wordcraft_doc::connector::ShapeExtra,
},
/// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at
/// byte `off` of paragraph `path` (its alt text).
Graphic {
rect: Rect,
graphic: Arc<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). A vertically merged cell is
/// one `Cell` as tall as all its rows; its continuation rows get a `covered` `Cell` (their
/// own row's height) so the rows keep a box each, but it isn't a cell of its own on screen.
Cell {
rect: Rect,
covered: bool,
table: Path,
row: usize,
cell: usize,
story: StoryRef,
},
/// The area of a picture, shape or text box, for hit testing, selection handles and dragging
/// (drawn by `Image`/`Shape`/`Lines`). The object is the U+FFFC at byte `off` of paragraph
/// `path` in `story`. `rect` is its unrotated frame; `spin` turns it about the centre.
Object {
rect: Rect,
spin: Spin,
story: StoryRef,
path: Path,
off: usize,
/// The text box story it shows (`Document::parts` id).
text_box: Option<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,
/// Width of the markup area on the right of `w` that holds comment balloons (Print Markup,
/// [`LayoutOptions::comment_balloons`]); 0 without one. The document page is `w - markup` wide.
pub markup: f32,
}
/// The whole layout.
#[derive(Clone, Debug, Default)]
pub struct DocLayout {
pub pages: Vec<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,
grid: (u32, u32, u32, bool),
hide_deleted: bool,
}
/// 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();
// Outline view lays paragraphs out changed (no indents; plain without text formatting).
let outline = (opts.view == ViewMode::Outline).then(|| opts.outline.as_ref().is_some_and(|o| o.plain));
hash_of(&(
s,
opts.show_hidden,
opts.hide_deleted,
opts.deletions_in_balloons,
opts.proofing,
opts.field_codes,
opts.proof,
wordcraft_proof::dictionary_generation(),
hyphenation_key(doc),
outline,
))
}
/// The hyphenation settings, hashable (the zone as its bits).
fn hyphenation_key(doc: &Document) -> (bool, u32, bool, u32) {
let s = &doc.settings;
(s.auto_hyphenation, s.hyphenation_zone.to_bits(), s.hyphenate_caps, s.consecutive_hyphen_limit)
}
fn has_page_fields(p: &Paragraph) -> bool {
p.objects.iter().any(|o| match o {
InlineObject::Field { instr, .. } => {
matches!(fields::field_name(instr).as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION")
}
_ => false,
})
}
/// Does `p` show a page total, which depends on how many pages the document has?
fn has_total_fields(p: &Paragraph) -> bool {
p.objects.iter().any(|o| match o {
InlineObject::Field { instr, .. } => matches!(fields::field_name(instr).as_str(), "NUMPAGES" | "SECTIONPAGES"),
_ => false,
})
}
/// Page totals: of the document, and of each section (by index).
#[derive(Clone, Debug, PartialEq)]
struct Totals {
pages: u32,
sections: Vec<u32>,
}
impl Totals {
/// What a first pass assumes: one page.
fn first_guess(sections: usize) -> Totals {
Totals { pages: 1, sections: vec![1; sections] }
}
/// The totals of laid-out `pages` (a section without a page of its own counts one, as in
/// headers and footers).
fn of(pages: &[Page], sections: usize) -> Totals {
let mut per = vec![0u32; sections];
for p in pages {
if let Some(n) = per.get_mut(p.section) {
*n = n.saturating_add(1);
}
}
for n in &mut per {
*n = (*n).max(1);
}
Totals { pages: u32::try_from(pages.len()).unwrap_or(u32::MAX).max(1), sections: per }
}
fn section(&self, si: usize) -> u32 {
self.sections.get(si).copied().unwrap_or(1)
}
}
/// The most extra layout passes for page totals in the body: a total that changes the page
/// count (its digits pushing text to another page) is tried again, this many times at most.
const MAX_TOTAL_PASSES: usize = 3;
/// A layout pass: finished, or to be done again with the page totals it found.
enum Pass {
Done(DocLayout),
Again(Totals),
}
/// Walk state while paginating.
struct Ctx<'a> {
doc: &'a Document,
cache: &'a mut LayoutCache,
opts: &'a LayoutOptions,
counters: Counters,
fields: FieldCtx,
notes_hash: u64,
numbers: HashMap<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,
/// The current section's document grid (body text only: none for headers and footers).
grid: para::Grid,
/// Whether tracked deletions are left out in the story being laid out
/// ([`LayoutOptions::hides_deleted`]): the body's, or the story of the [`layout_box`] at work.
hide_deleted: bool,
/// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`,
/// `SECTIONPAGES`), which is only known once the whole body is paginated.
uses_totals: bool,
/// Whether to note `uses_totals` now (off while laying out headers and footers).
track_totals: bool,
}
impl Ctx<'_> {
/// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]);
/// pictures' are worked out once per picture and crop.
fn wrap_outline(&mut self, o: &InlineObject) -> Option<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: None,
exclusions: &[],
eq_number: 0,
grid: para::Grid::default(),
};
let pl = Arc::new(para::layout_para(&p, &env));
self.numbers.insert(n, pl.clone());
pl
}
/// The list label of `p`, advancing the list counters: call it once per paragraph, in
/// document order (only for paragraphs that will show).
fn next_label(&mut self, p: &Paragraph) -> Option<(String, Level)> {
let n = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?;
self.counters.next_label(&self.doc.numbering, n.num, n.level)
}
/// Lay out `p` with its already counted `label`, so laying a paragraph out again (around
/// floating objects, or after it moved) doesn't advance the list counters twice.
fn para_labelled(
&mut self,
p: &Paragraph,
width: f32,
table: Option<&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 };
if self.track_totals && !self.uses_totals && has_total_fields(p) {
self.uses_totals = true;
}
// Automatic equation numbers count through the document.
let eq_here: u32 = p
.objects
.iter()
.map(|o| match o {
InlineObject::Equation { math, display, .. } => math::auto_numbers(math, *display),
_ => 0,
})
.sum();
let eq_number = self.eq_count;
self.eq_count = self.eq_count.saturating_add(eq_here);
let key = Key {
rev: p.rev,
width: width.to_bits(),
label: label.as_ref().map(|(t, l)| format!("{t}|{}|{}|{:?}|{:?}|{:?}|{:?}", l.indent, l.hanging, l.suffix, l.align, l.tab, l.chr)),
page,
table: table.map(|t| hash_of(&format!("{t:?}"))).unwrap_or(0),
notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 },
excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) },
eq: if eq_here > 0 { eq_number } else { 0 },
grid: self.grid.key(),
hide_deleted: self.hide_deleted,
};
self.cache.used.insert(key.clone());
if let Some(pl) = self.cache.paras.get(&key) {
self.cache.hits += 1;
return pl.clone();
}
self.cache.misses += 1;
let env = para::ParaEnv {
doc: self.doc,
width,
label,
fields: &self.fields,
show_hidden: self.opts.show_hidden,
field_codes: self.opts.field_codes,
hide_deleted: self.hide_deleted,
table,
proofing: self.opts.proofing.then_some(self.opts.proof),
exclusions,
eq_number,
grid: self.grid,
};
let pl = Arc::new(para::layout_para(p, &env));
self.cache.paras.insert(key, pl.clone());
pl
}
}
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
/// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the
/// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is
/// laid out with (`StyleSheet::resolve_char_in`).
fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool {
let mut left = Vec::new();
// Nothing can be left out with hidden text shown and markup on.
if (hide_deleted || !show_hidden) && !p.objects.is_empty() {
let style = p.props.style.as_deref();
let is_left = |c: &CharProps| {
let rc = doc.styles.resolve_char_in(style, table_chr, c);
para::left_out(&rc, show_hidden, hide_deleted)
};
// Runs and objects are both in order: one walk finds each object's run (past the last run,
// the paragraph mark, as `props_of_char` gives), resolving a run's formatting only once.
let mut runs = p.run_ranges().peekable();
let mut last: Option<(usize, bool)> = None;
for off in p.object_offsets() {
while runs.next_if(|(r, _)| r.end <= off).is_some() {}
let l = match runs.peek() {
Some((r, c)) => match last {
Some((end, l)) if end == r.end => l,
_ => {
let l = is_left(c);
last = Some((r.end, l));
l
}
},
None => is_left(&p.mark),
};
left.push(l);
}
}
move |k| left.get(k).copied().unwrap_or(false)
}
/// Footnote and endnote numbers (each kind counted separately), in document order, as each
/// section's options say: starting at its "start at", numbered on, or starting again in each
/// section or (footnotes) on each page. `pages` gives the page each footnote was placed on by an
/// earlier pass (empty: numbered on). With `hide_deleted`, notes whose reference mark is a
/// tracked deletion are left out, as in the final text.
fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap<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 {
// Deletions in balloons leave the body's text only: a header, note or text box keeps its own
// inline (restored for the story around this box when it's done).
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
let mut items = Vec::new();
// Pictures behind the text go first; `behind` counts them.
let mut behind = 0;
// Wrap areas of the box's floating objects.
let mut excl: Vec<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.hide_deleted);
for (oi, o) in p.objects.iter().enumerate() {
let Some((w, h, float)) = floating(o) else { continue };
if left_out(oi) {
continue;
}
let r = float_rect(frame, (0.0, width), y, w, h, float);
let outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())));
floats.insert(oi, r);
}
let rel = rel_exclusions(&excl, 0.0, y);
if !rel.is_empty() {
pl = ctx.para_labelled(p, width, table, &rel, label);
}
let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0));
push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width);
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
if !p.objects.is_empty() {
let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr) };
let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth);
let at = behind.min(items.len());
behind += back.len();
items.splice(at..at, back);
items.extend(front);
}
y += pl.height + pl.rp.space_after;
prev_after = if same && ctxl { 0.0 } else { pl.rp.space_after };
prev_style = Some((pl.rp.style.clone(), ctxl));
}
Block::Table(t) => {
if depth > 8 {
continue;
}
let tl = table::layout_table(ctx, story, t, &path, width, depth + 1);
let (tx, ty) = match t.props.float {
// A floating table stands at its own position and takes no room; the text
// after it wraps around it.
Some(f) => {
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables);
excl.push((area, false, None, WrapText::BothSides));
float_tables.push(area);
(r.x, r.y)
}
// A table in the text flow starts below the floating objects in its way.
None => (tl.x, below(&excl, y, tl.x, tl.x + tl.width)),
};
let mut row_y = ty;
for row in &tl.rows {
for it in &row.items {
let mut it = it.clone();
it.translate(tx, row_y);
items.push(it);
}
row_y += row.height;
}
if t.props.float.is_none() {
y = row_y;
prev_after = 0.0;
prev_style = None;
}
}
}
}
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
ctx.hide_deleted = outer_hide;
BoxLayout { items, height: y.max(0.0), floats_bottom }
}
/// A laid-out box: items relative to its top-left, the height of its text flow, and where its
/// floating tables end (a table cell grows to hold them).
struct BoxLayout {
items: Vec<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 default internal margins (Word's: 0.1" left/right, 0.05" top/bottom; a box's own
/// are `TextBody::inset_pts`).
#[cfg(test)]
const BOX_INSET_X: f32 = 7.2;
#[cfg(test)]
const BOX_INSET_Y: f32 = 3.6;
/// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are
/// dropped (whole lines; a line that only partly fits too) and so is anything starting below.
/// The first line always stays, so even a tiny box shows the start of its text.
fn fit_box(items: Vec<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,
/// Top of the footer of the pages being started: a footer reaching above the bottom margin
/// pushes the body up (`f32::MAX`: no footer).
foot_top: f32,
}
/// Gap above the footnote separator and its length.
const NOTE_SEP: f32 = 12.0;
/// Length of the default (short) note separator line.
pub(crate) const NOTE_SEP_W: f32 = 144.0;
/// The story of text drawn on a page that belongs to no story of the document (line numbers,
/// note separators and continuation notices): not hit by clicks.
pub const DECOR_STORY: StoryRef = StoryRef::Part(u32::MAX);
/// A note separator or continuation notice laid out from y = 0 (its items and height).
type SepBox = (Vec<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 })
}
}
/// Page borders, measured from the page edge (Word's default), so they surround the header,
/// footer and line numbers. The first items of every printed page.
fn page_border_rules(s: &SectionProps) -> Vec<Placed> {
let mut v = Vec::new();
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) {
v.push(Placed::Rule { x0: a.0, y0: a.1, x1: bb.0, y1: bb.1, border: e });
}
}
}
v
}
impl PageBuilder<'_> {
/// Space footnotes take at the bottom of the page.
fn notes_h(&self) -> f32 {
match self.notes.first() {
None => 0.0,
Some(first) => self.seps.sep_h(first.cont) + self.notes.iter().map(|n| n.h).sum::<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) };
// A footer taller than the bottom margin pushes the body up (Word: no gap), leaving it at
// least a little room.
let bottom = (s.page_h - s.margin_bottom).min(self.foot_top.max(body_top + 36.0));
let body = Rect::new(s.margin_left + s.gutter, body_top, s.text_width(), (bottom - body_top).max(36.0));
let mut decor = Vec::new();
if !self.web {
decor = page_border_rules(s);
// Lines between columns.
if s.columns.separator && s.columns.count > 1 {
let cols = s.column_boxes();
for w in cols.windows(2) {
if let [(ax, aw), (bx, bw)] = w {
// Midway across the space between neighbours (leftwards in an RTL section).
let mid = if bx >= ax { (ax + aw + bx) / 2.0 } else { (bx + bw + ax) / 2.0 };
let x = s.margin_left + s.gutter + mid;
decor.push(Placed::Rule { x0: x, y0: body_top, x1: x, y1: bottom, border: Border::single(0.5) });
}
}
}
}
self.page_items_start = decor.len();
self.pages.push(Page { w, h, section: self.sect_idx, number: self.number, body, first_block, items: decor, ..Default::default() });
self.behind.push(0);
self.col = 0;
self.cols = s.column_boxes();
self.top = body_top;
self.bottom = if self.web { f32::MAX / 8.0 } else { bottom };
self.orig_bottom = self.bottom;
self.y = self.top;
// Notes continued from the page before come first in this page's note area.
// (`cap_columns` below keeps the columns above them.)
if !self.carry.is_empty() && !self.web {
self.notes = std::mem::take(&mut self.carry);
}
self.max_y = self.top;
self.content_bottom = self.top;
self.cap_columns();
}
/// Where the columns end on this page: the page bottom above its footnotes, or higher up
/// when the columns are balanced.
fn cap_columns(&mut self) {
self.bottom = self.uncapped_bottom();
if let Some((page, h)) = self.balance
&& page + 1 == self.pages.len()
{
self.bottom = self.bottom.min(self.top + h);
}
}
/// The bottom of the columns without balancing: above the footnotes.
fn uncapped_bottom(&self) -> f32 {
self.orig_bottom - self.notes_h()
}
/// Below the text on this page: the tallest column's bottom.
fn text_bottom(&self) -> f32 {
self.max_y.max(self.y)
}
fn col_x(&self) -> f32 {
self.sect.margin_left + self.sect.gutter + self.cols.get(self.col).map(|c| c.0).unwrap_or(0.0)
}
fn col_w(&self) -> f32 {
self.cols.get(self.col).map(|c| c.1).unwrap_or_else(|| self.sect.text_width())
}
fn at_top(&self) -> bool {
self.y <= self.top + 0.01
}
/// Move to the next column or page.
fn advance(&mut self, block: usize, body_top: f32) {
if self.col + 1 < self.cols.len() {
self.max_y = self.max_y.max(self.y);
self.col += 1;
self.y = self.top;
} else {
self.new_page(block, body_top);
}
}
fn page(&mut self) -> Option<&mut Page> {
self.pages.last_mut()
}
}
/// Passes the layout makes at most for footnotes numbered again on each page: their numbers
/// depend on the pages they land on, which (barely) depend on the numbers' widths.
const MAX_NOTE_PASSES: usize = 3;
/// Lay out the whole document.
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
let t0 = now_ms();
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
// numbered again on each page likewise settle over a few more passes.
let per_page = opts.view == ViewMode::Print
&& doc.sections().iter().any(|(_, s)| doc.note_options(s, false).num_restart == wordcraft_doc::section::NoteRestart::EachPage);
let mut note_pages = HashMap::new();
let mut note_passes = 1;
let mut totals = Totals::first_guess(doc.sections().len());
let mut passes = 0;
let mut l = loop {
match layout_pass(doc, cache, opts, &note_pages, &totals, passes < MAX_TOTAL_PASSES) {
Pass::Again(t) => {
totals = t;
passes += 1;
}
Pass::Done(l) => {
if per_page && note_passes < MAX_NOTE_PASSES {
// Number each page's footnotes from where the pass before placed them, until they stay.
let placed = footnote_pages(doc, &l.pages);
if placed != note_pages {
note_pages = placed;
note_passes += 1;
continue;
}
}
break l;
}
}
};
l.ms = now_ms() - t0;
l
}
/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]),
/// with `totals` assumed for the page totals in the body. With `may_repeat`, a pass whose body
/// shows totals that turn out different asks to be done again.
fn layout_pass(
doc: &Document,
cache: &mut LayoutCache,
opts: &LayoutOptions,
note_pages: &HashMap<u32, usize>,
totals: &Totals,
may_repeat: bool,
) -> Pass {
let env = env_hash(doc, opts);
if env != cache.env {
cache.paras.clear();
cache.env = env;
}
cache.used.clear();
// Note references are counted as the body shows them.
let notes = note_info(doc, opts.hides_deleted(StoryRef::Body), note_pages);
let notes_hash = hash_of(&{
let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, b.n, b.fmt)).collect();
v.sort_by_key(|(a, ..)| *a);
v
});
let mut ctx = Ctx {
doc,
cache,
opts,
hide_deleted: opts.hides_deleted(StoryRef::Body),
counters: Counters::default(),
fields: FieldCtx {
page: 1,
pages: totals.pages,
section_pages: totals.section(0),
section: 1,
notes: Arc::new(notes),
title: doc.core.title.as_str().into(),
author: doc.core.creator.as_str().into(),
..Default::default()
},
notes_hash,
numbers: HashMap::new(),
eq_count: 0,
boxes: wordcraft_doc::BoxBudget::default(),
chains: Default::default(),
grid: para::Grid::default(),
uses_totals: false,
track_totals: true,
};
let sections = doc.sections();
let web = opts.view != ViewMode::Print;
let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default()));
let default_sect = SectionProps::default();
let mut web_sect;
let first_sect = match sections.first() {
Some((_, s)) => *s,
None => &default_sect,
};
let sect_ref = if web {
web_sect = first_sect.clone();
web_sect.page_w = opts.web_width.max(144.0);
web_sect.margin_left = 18.0;
web_sect.margin_right = 18.0;
web_sect.margin_top = 18.0;
web_sect.gutter = 0.0;
web_sect.columns = Default::default();
&web_sect
} else {
first_sect
};
let mut pb = PageBuilder {
pages: Vec::new(),
sect: sect_ref,
sect_idx: 0,
col: 0,
cols: Vec::new(),
y: 0.0,
top: 0.0,
bottom: 0.0,
number: 0,
web,
notes: Vec::new(),
carry: Vec::new(),
orig_bottom: 0.0,
prev: None,
excl: Vec::new(),
float_tables: Vec::new(),
line_no: 0,
page_items_start: 0,
seps: NoteSeps::default(),
behind: Vec::new(),
max_y: 0.0,
content_bottom: 0.0,
balance: None,
col_break_page: None,
sect_beneath: false,
beneath: false,
foot_top: f32::MAX,
};
// Endnotes in document order, with the section of their reference; placed at the end of a
// section that keeps them there, else at the end of the document.
let mut endnotes: std::collections::VecDeque<(u32, usize)> = {
let mut ids: Vec<(u32, u32, usize)> = ctx
.fields
.notes
.iter()
.filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote))
.map(|(id, n)| (n.order, *id, n.section))
.collect();
ids.sort();
ids.into_iter().map(|(_, id, sect)| (id, sect)).collect()
};
// Text boxes laid out in balancing attempts that were thrown away.
let mut boxes_discarded = 0usize;
let mut block = 0usize;
// Vertical sections are laid out on their pages turned on their side (see `vertical`).
let turned: Vec<Option<SectionProps>> = sections
.iter()
.map(|(_, s)| {
(!web && s.text_direction.is_turned()).then(|| {
let top = body_top_for(&mut ctx, s, s.headers.default);
vertical::unturned_sect(s, top)
})
})
.collect();
let mut prev_turn = TextDirection::Horizontal;
for (si, (end, sect)) in sections.iter().enumerate() {
let turn = if web { TextDirection::Horizontal } else { sect.text_direction };
let sect: &SectionProps = match turned.get(si) {
Some(Some(t)) => t,
_ if web => sect_ref,
_ => sect,
};
pb.sect = sect;
pb.sect_idx = si;
ctx.grid = para::Grid::default();
let (foot, end_opts) = (doc.note_options(sect, false), doc.note_options(sect, true));
pb.sect_beneath = foot.pos == wordcraft_doc::section::NotePos::BeneathText;
if !web && !doc.footnote_separators.is_empty() {
pb.seps = NoteSeps::new(&mut ctx, &doc.footnote_separators, sect.text_width());
}
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
ctx.fields.section_pages = totals.section(si);
// Headers and footers left out are linked to the previous section's, and take its room.
let (heads, feet) = (linked(&sections, si, |s| s.headers), linked(&sections, si, |s| s.footers));
let body_top = if web || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
let first_top = if web || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) };
let foot_top = if web || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) };
let first_foot = if web || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) };
ctx.grid = para::Grid::of(doc, sect);
let restart = sect.page_num_start;
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
pb.line_no = 0;
}
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
if web && si > 0 {
// one long page
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
// A page holds text running one way: a section turning the other way starts a page.
if let Some(n) = restart {
pb.number = n.saturating_sub(1);
}
pb.foot_top = first_foot;
pb.new_page(block, first_top);
pb.foot_top = foot_top;
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
let want_even = start == SectionStart::EvenPage;
if pb.number.is_multiple_of(2) != want_even {
pb.new_page(block, body_top);
}
}
} else {
// Continuous: new column layout below the current text (its tallest column).
pb.cols = sect.column_boxes();
pb.col = 0;
pb.top = pb.text_bottom();
pb.y = pb.top;
pb.max_y = pb.top;
}
prev_turn = turn;
pb.foot_top = foot_top;
let last = (*end).min(doc.body.len().saturating_sub(1));
// Newspaper columns are balanced on the section's last page when the next section starts
// on the same page (a continuous break), as in Word. That page is laid out again from the
// start of the block that reaches it, with shorter columns (see `balance_columns`).
let balance = !web
&& pb.cols.len() > 1
&& sect.valign == wordcraft_doc::props::VAlign::Top
&& sections.get(si + 1).is_some_and(|(_, s)| s.start == SectionStart::Continuous);
pb.col_break_page = None;
let mut from = balance.then(|| (block, Snap::take(&mut pb, &ctx)));
while block <= last {
let Some(b) = doc.body.get(block) else { break };
let before = balance.then(|| (pb.pages.len(), Snap::take(&mut pb, &ctx)));
match (&**b, &outline) {
(Block::Para(p), Some(o)) => place_outline_para(&mut ctx, &mut pb, p, block, o),
(Block::Table(_), Some(o)) if o.hidden.get(block).copied().unwrap_or(false) => {}
(Block::Para(p), None) => place_para(&mut ctx, &mut pb, p, block, body_top),
(Block::Table(t), _) => place_table(&mut ctx, &mut pb, t, block, body_top),
}
if let Some((pages, snap)) = before
&& pb.pages.len() != pages
{
from = Some((block, snap));
}
block += 1;
}
if let Some((first, snap)) = from {
boxes_discarded += balance_columns(&mut ctx, &mut pb, (first, block), &snap, body_top);
}
// Endnotes at the end of the section: those referenced so far.
if end_opts.pos == wordcraft_doc::section::NotePos::SectEnd && !pb.pages.is_empty() {
let mut here = Vec::new();
while let Some((id, _)) = endnotes.front().filter(|(_, s)| *s <= si).copied() {
endnotes.pop_front();
here.push(id);
}
place_endnotes(&mut ctx, &mut pb, here, block.saturating_sub(1));
}
}
if pb.pages.is_empty() {
pb.new_page(0, sect_ref.margin_top);
}
// The rest of the endnotes after the last paragraph.
let rest: Vec<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;
for p in &mut pages {
if let Some((_, s)) = sections.get(p.section)
&& !web
{
vertical::turn_page(p, s.text_direction, s);
}
}
if web && let Some(p) = pages.first_mut() {
let bottom = p
.items
.iter()
.filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None })
.fold(0.0f32, f32::max);
p.h = bottom + 36.0;
}
ctx.grid = para::Grid::default();
if !web && ctx.uses_totals && may_repeat {
let found = Totals::of(&pages, sections.len());
if found != *totals {
return Pass::Again(found);
}
}
if !web {
ctx.track_totals = false;
headers_footers(&mut ctx, &mut pages, &sections);
if doc.settings.mirror_margins {
mirror_even_pages(&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));
}
}
}
let mut lay = DocLayout { pages, index, ms: 0.0, text_boxes: 0 };
if opts.comment_balloons && !web {
balloons::add(&mut ctx, &mut lay);
}
lay.text_boxes = ctx.boxes.total().saturating_sub(boxes_discarded);
Pass::Done(lay)
}
/// Pagination state saved before a block, to lay the blocks from there out again (balancing
/// columns): the builder without its pages, how far its last page had got, and the walk state.
struct Snap<'a> {
pb: PageBuilder<'a>,
pages: usize,
items: usize,
behind: usize,
counters: Counters,
eq_count: u32,
page_field: u32,
boxes: usize,
}
impl<'a> Snap<'a> {
fn take(pb: &mut PageBuilder<'a>, ctx: &Ctx) -> Self {
// The pages stay out of the copy: it would cost the whole document per block.
let (pages, behind) = (std::mem::take(&mut pb.pages), std::mem::take(&mut pb.behind));
let copy = pb.clone();
(pb.pages, pb.behind) = (pages, behind);
Snap {
pb: copy,
pages: pb.pages.len(),
items: pb.pages.last().map_or(0, |p| p.items.len()),
behind: pb.behind.last().copied().unwrap_or(0),
counters: ctx.counters.clone(),
eq_count: ctx.eq_count,
page_field: ctx.fields.page,
boxes: ctx.boxes.total(),
}
}
/// Go back to this state: drop the pages added since and what was added to the last one
/// (appended, or inserted at the front behind the text).
fn restore(&self, pb: &mut PageBuilder<'a>, ctx: &mut Ctx) {
let (mut pages, mut behind) = (std::mem::take(&mut pb.pages), std::mem::take(&mut pb.behind));
pages.truncate(self.pages);
behind.truncate(self.pages);
if let Some(pg) = pages.last_mut() {
let k = behind.last().copied().unwrap_or(0).saturating_sub(self.behind).min(pg.items.len());
pg.items.drain(..k);
pg.items.truncate(self.items);
}
if let Some(b) = behind.last_mut() {
*b = self.behind;
}
*pb = self.pb.clone();
(pb.pages, pb.behind) = (pages, behind);
ctx.counters = self.counters.clone();
ctx.eq_count = self.eq_count;
ctx.fields.page = self.page_field;
}
}
/// One balancing pass: blocks `first..end` laid out again from `snap` with the columns on `page`
/// ending `h` below `top`; true if the page's text fits them.
fn balance_pass<'a>(
ctx: &mut Ctx,
pb: &mut PageBuilder<'a>,
snap: &Snap<'a>,
(first, end): (usize, usize),
(page, top, h): (usize, f32, f32),
body_top: f32,
) -> bool {
snap.restore(pb, ctx);
pb.balance = Some((page, h));
pb.cap_columns();
for block in first..end {
match ctx.doc.body.get(block).map(|b| &**b) {
Some(Block::Para(p)) => place_para(ctx, pb, p, block, body_top),
Some(Block::Table(t)) => place_table(ctx, pb, t, block, body_top),
None => break,
}
// Spilled onto another page: too short (and no need to lay out the rest).
if pb.pages.len() > page + 1 {
break;
}
}
pb.balance = None;
pb.cap_columns();
pb.pages.len() == page + 1 && pb.content_bottom <= top + h + 0.5
}
/// Balancing lays out at most this many pages again per pass (a block reaching the last page
/// from further back is left unbalanced), so its cost stays a small multiple of one page.
const BALANCE_MAX_PAGES: usize = 16;
/// Passes of the binary search on the column height (enough for well under a point).
const BALANCE_PASSES: usize = 12;
/// Balance the columns on the last page of a section followed by a continuous break, as Word
/// does: lay blocks `first..end` out again from `snap` (taken before `first`) with the shortest
/// columns that still hold that page's text — a binary search on the column height, where a
/// height fits when nothing moves to another page and nothing (an unsplittable row, a line
/// taller than the column) runs past it. Keep-together, keep-with-next and widow/orphan control
/// apply as usual, so the columns may come out a little uneven. Not balanced: a page with an
/// explicit column break, which says where the columns end. Returns the text boxes laid out in the
/// passes thrown away.
fn balance_columns<'a>(ctx: &mut Ctx, pb: &mut PageBuilder<'a>, blocks: (usize, usize), snap: &Snap<'a>, body_top: f32) -> usize {
let page = pb.pages.len().saturating_sub(1);
let ncols = pb.cols.len().max(1) as f32;
let top = pb.top;
let full = pb.uncapped_bottom() - top;
let used = pb.content_bottom.min(pb.uncapped_bottom()) - top;
if pb.col_break_page == Some(page)
|| snap.pages == 0
|| (page + 1).saturating_sub(snap.pages) > BALANCE_MAX_PAGES
|| used <= 1.0
|| full <= 1.0
|| pb.cols.len() < 2
{
return 0;
}
let pass = |ctx: &mut Ctx, pb: &mut PageBuilder<'a>, h: f32| balance_pass(ctx, pb, snap, blocks, (page, top, h), body_top);
// The text on the page fills `ncols` columns at least `used / ncols` high; the original layout
// (`full`) fits, and so, nearly always, do columns as high as its tallest one.
let mut lo = (used / ncols - 1.0).max(0.0);
let mut hi = full;
let mut last = None;
let discard = |ctx: &Ctx| ctx.boxes.total().saturating_sub(snap.boxes);
let mut thrown = discard(ctx);
if used < full - 0.5 {
let ok = pass(ctx, pb, used);
if ok {
hi = used;
}
last = Some((used, ok));
}
for _ in 0..BALANCE_PASSES {
if hi - lo <= 1.0 {
break;
}
let mid = (lo + hi) / 2.0;
thrown = discard(ctx);
let ok = pass(ctx, pb, mid);
if ok {
hi = mid;
} else {
lo = mid;
}
last = Some((mid, ok));
}
// End on the best height (the full column, as laid out first, when none was shorter).
if last != Some((hi, true)) {
thrown = discard(ctx);
if !pass(ctx, pb, hi) && hi < full {
thrown = discard(ctx);
pass(ctx, pb, full);
}
}
thrown
}
/// Place endnotes `ids` below the text so far: a separator (the document's own, else a short
/// line), then each note, moving to the next column or page when one doesn't fit.
fn place_endnotes(ctx: &mut Ctx, pb: &mut PageBuilder, ids: Vec<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;
}
let mut added = 0usize;
for id in ids {
let Some(part) = ctx.doc.parts.get(&id) else { continue };
let blocks = part.blocks.clone();
let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0, None);
// A note longer than the room left continues at a line break in the next column or on
// a new page (Word). Each page takes at least a line, so this ends; the page cap is a
// backstop (the rest then runs off the last page).
let mut note = NoteBox { id, items, h, cont: false };
loop {
let room = pb.bottom - pb.y;
let cut = if note.h <= room + 0.01 || added >= MAX_NOTE_PAGES {
None
} else {
let cuts = note.cuts();
match cuts.iter().rev().find(|&&c| c <= room + 0.01).or(cuts.first().filter(|_| pb.at_top())) {
Some(&c) => Some(c),
None if pb.at_top() => None,
None => {
pb.advance(block, pb.top);
added += 1;
continue;
}
}
};
let (head, rest) = match cut {
Some(c) => {
let (head, rest) = note.split(c);
(head, Some(rest))
}
None => (note, None),
};
let (x, y) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
for mut it in head.items {
it.translate(x, y);
pg.items.push(it);
}
}
pb.y += head.h;
let Some(rest) = rest else { break };
note = rest;
pb.advance(block, pb.top);
added += 1;
}
}
}
fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<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 track = std::mem::replace(&mut ctx.track_totals, false);
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height;
ctx.track_totals = track;
// Word starts the body right below a header that reaches past the top margin, no gap.
sect.margin_top.max(sect.header + h)
}
/// Where the footer starts (as `headers_footers` places it), `f32::MAX` without one.
fn foot_top_for(ctx: &mut Ctx, sect: &SectionProps, footer: Option<u32>) -> f32 {
let Some(part) = footer.and_then(|id| ctx.doc.parts.get(&id).map(|p| (id, p))) else { return f32::MAX };
let (id, blocks) = (part.0, part.1.blocks.clone());
// A page total in the footer is filled in after pagination, like the header's: it doesn't
// make the body lay out again.
let track = std::mem::replace(&mut ctx.track_totals, false);
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, None).height;
ctx.track_totals = track;
sect.page_h - sect.footer - h
}
/// Where a box of laid-out items sits on its page, for floating objects positioned relative to
/// the page or its margins.
#[derive(Clone, Copy)]
struct PageFrame<'a> {
sect: &'a SectionProps,
/// Page position of the box's (0, 0); (0, 0) for body text.
origin: (f32, f32),
}
/// A floating (not inline) picture, chart or shape: its size and placement.
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline)
}
/// Where a floating object goes, in the coordinates of the box `frame` places on the page.
/// `col` is the column (or cell) left edge and width, `y0` the anchor paragraph's top. Without a
/// frame (table cells, text boxes, notes) positions relative to the page or margins fall back
/// to the box itself.
fn float_rect(frame: Option<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, extra, .. } => {
vec![Placed::Shape {
rect,
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
effects: *effects,
freeform: freeform.clone(),
spin,
extra: extra.clone(),
}]
}
InlineObject::Graphic { graphic, .. } => {
vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off, spin }]
}
InlineObject::Group { .. } => {
let centre = Point::new(rect.x + rect.w / 2.0, rect.y + rect.h / 2.0);
match o.canvas_style() {
// A Drawing Canvas: its background and outline, then its members clipped to its
// frame, all turned together: the members sit in the unturned frame and the clip
// carries the turn (drawn as the turn around the clip).
Some(c) => {
let members = group_members(o, rect)
.into_iter()
.flat_map(|(r, m)| float_items(m, r, (Spin::default(), centre), story, path, off))
.collect();
vec![
Placed::Shape {
rect,
kind: wordcraft_doc::para::ShapeKind::Rectangle,
fill: c.fill,
stroke: c.stroke,
stroke_width: c.stroke_width,
effects: Default::default(),
freeform: None,
spin,
extra: Default::default(),
},
Placed::Clip { rect, spin, items: members },
]
}
None => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, (spin, centre), story, path, off)).collect(),
}
}
_ => Vec::new(),
}
}
/// A group's members, each with its rectangle when the group is at `rect`.
/// `rect` moved (not turned) to where `spin` about `centre` takes its centre: a group member's
/// frame inside its turned group.
fn carried(rect: Rect, spin: Spin, centre: Point) -> Rect {
let (cx, cy) = (rect.x + rect.w / 2.0, rect.y + rect.h / 2.0);
let (nx, ny) = spin.apply(centre.x, centre.y, cx, cy);
Rect::new(rect.x + nx - cx, rect.y + ny - cy, rect.w, rect.h)
}
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
}
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
/// the current column, add their wrap areas to the page (replacing any from an earlier attempt,
/// from `excl_mark` on) and lay the paragraph out around them, with its list `label`.
fn anchor_floats(
ctx: &mut Ctx,
pb: &mut PageBuilder,
p: &Paragraph,
y0: f32,
excl_mark: usize,
label: &Option<(String, Level)>,
) -> (HashMap<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.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.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;
}
// A line holding nothing but a page or column break stays at the bottom of the page
// even when it doesn't fit: it only ends the page, and moving it would leave a blank
// page behind it (Word does the same).
let only_break = matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak)
&& p.text
.get(l.start..l.stop)
.is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK)));
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break {
break;
}
new_notes.extend(line_notes);
l1 += 1;
if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web {
forced_break = Some(l.end);
break;
}
if split {
break;
}
}
// Widow/orphan control (2-line minimum at either end).
if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 {
if l1 - l0 == 1 && l0 == 0 && !pb.at_top() {
l1 = l0; // orphan: move the first line to the next page
} else if n - l1 == 1 && l1 - l0 >= 3 {
l1 -= 1; // widow: take one more line along
}
}
// A row (text both sides of a floating object) stays together.
while l1 > l0 && l1 < n && pl.lines.get(l1).is_some_and(|l| l.beside) {
l1 -= 1;
}
if l1 == l0 {
if pb.at_top() {
l1 = l0 + 1; // can't fit even one line on an empty page: overflow
} else {
pb.advance(block, body_top);
if l0 == 0 {
let y0 = pb.y;
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label);
anchor_y = y0;
n = pl.lines.len();
}
continue;
}
}
// Keep only the notes of the lines that are placed.
let placed_end = pl.lines.get(l1.saturating_sub(1)).map(|l| l.c1).unwrap_or(0);
new_notes.retain(|n| notes.iter().any(|(ci, nid)| *nid == n.id && *ci < placed_end));
let before = pb.notes_h();
pb.notes.extend(new_notes);
if pb.balance.is_some() {
// Balanced columns end where the balance puts them unless the notes reach higher.
pb.cap_columns();
} else {
// Relative, so a bottom moved down for notes continuing on the next page stays put.
pb.bottom -= pb.notes_h() - before;
}
// Mirror indents: on an even (left-hand) page the inside indent is on the right, so the
// paragraph's lines (broken for the same width) move over by the indents' difference.
let x = pb.col_x() + mirror_shift(&pl.rp, pb.number, pb.web);
let y = pb.y + lead;
// Notes that no longer fit below the text (a long one, or the rest of one continued from
// the page before) get what's left under it and continue on the next page.
if !pb.notes.is_empty()
&& !pb.web
&& let Some(placed_bottom) = item_bottom(y, &pl, l0, l1)
&& placed_bottom > pb.bottom + 0.01
{
pb.bottom = pb.body_floor().max(placed_bottom).min(pb.orig_bottom);
}
let mut items = Vec::new();
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width);
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
let at = ObjFrame {
page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }),
col: (x, width),
para_y: if pb.web { y } else { anchor_y },
table_chr: None,
};
let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], p, &pl, (l0, l1), (x, y), &at, &float_rects, 0);
items.extend(front);
// Line numbers in the left margin.
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers)
&& let Some(first) = pl.lines.get(l0)
{
for li in l0..l1 {
let Some(line) = pl.lines.get(li).filter(|l| !l.beside) else { continue };
pb.line_no = pb.line_no.saturating_add(1);
let n = pb.line_no.saturating_add(ln.start.saturating_sub(1));
if ln.count_by > 1 && !n.is_multiple_of(ln.count_by) {
continue;
}
let num = ctx.number_para(n);
let w = num.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(10.0);
let dist = if ln.distance > 0.0 { ln.distance } else { 18.0 };
let ly = y + (line.top - first.top) + (line.baseline - line.top) - num.lines.first().map(|l| l.baseline).unwrap_or(10.0);
items.push(Placed::Lines {
story: DECOR_STORY,
path: Path(vec![]),
para: num.clone(),
l0: 0,
l1: 1,
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
y: ly,
turn: TextDirection::Horizontal,
});
}
}
let n_behind = behind.len();
if let Some(pg) = pb.pages.last_mut() {
for (i, it) in behind.into_iter().enumerate() {
pg.items.insert(i.min(pg.items.len()), it);
}
pg.items.extend(items);
}
if let Some(b) = pb.behind.last_mut() {
*b += n_behind;
}
if let (Some(f), Some(l)) = (pl.lines.get(l0), pl.lines.get(l1 - 1)) {
pb.y = y + (l.top + l.height - f.top);
pb.content_bottom = pb.content_bottom.max(pb.y);
}
l0 = l1;
match forced_break {
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
Some(LineEnd::ColumnBreak) => {
pb.col_break_page = Some(pb.pages.len().saturating_sub(1));
pb.advance(block, body_top)
}
_ if l0 < n => pb.advance(block, body_top),
_ => {}
}
}
pb.y += pl.rp.space_after;
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after));
}
/// The outline of the body for an Outline view pass.
struct OutlinePass {
view: outline::OutlineView,
levels: Vec<u8>,
hidden: Vec<bool>,
}
impl OutlinePass {
fn new(doc: &Document, view: outline::OutlineView) -> Self {
let levels = outline::levels(doc);
let hidden = outline::hidden(&levels, &view);
OutlinePass { view, levels, hidden }
}
}
/// A body paragraph in Outline view (one long sheet: no page or column breaks, no floats
/// wrapping): indented by its level behind its symbol; hidden when collapsed or deeper than Show
/// Level; body text cut to its first line with Show First Line Only.
fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, o: &OutlinePass) {
// List numbers count the hidden paragraphs too.
let label = ctx.next_label(p);
if o.hidden.get(block).copied().unwrap_or(false) {
return;
}
let level = o.levels.get(block).copied().unwrap_or(outline::BODY);
let indent = outline::indent(&o.levels, block);
let x = pb.col_x() + indent + outline::SYMBOL_W;
let width = (pb.col_w() - indent - outline::SYMBOL_W).max(72.0);
let q = outline::outline_para(ctx.doc, p, o.view.plain);
ctx.fields.page = pb.number;
let pl = ctx.para_labelled(&q, width, None, &[], label);
pb.y += pl.rp.space_before.max(0.0);
let n = pl.lines.len();
let l1 = if o.view.first_line_only && level >= outline::BODY { n.min(1) } else { n };
let y = pb.y + pl.lines.first().map(|f| f.top.max(0.0)).unwrap_or(0.0);
let mut items = Vec::new();
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, 0, l1, x, y, width);
let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }), col: (x, width), para_y: y, table_chr: None };
let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], &q, &pl, (0, l1), (x, y), &at, &HashMap::new(), 0);
items.extend(front);
let children = level < outline::BODY && outline::has_children(&o.levels, block);
// Collapsed, or with something under it hidden by Show Level: the wavy line says so.
let collapsed = children && o.hidden.get(block + 1).copied().unwrap_or(false);
items.extend(outline::symbol_items(&pl, x, y, level, children, collapsed));
if let Some(pg) = pb.page() {
for (i, it) in behind.into_iter().enumerate() {
pg.items.insert(i.min(pg.items.len()), it);
}
pg.items.extend(items);
}
if let (Some(f), Some(l)) = (pl.lines.first(), l1.checked_sub(1).and_then(|k| pl.lines.get(k))) {
pb.y = y + (l.top + l.height - f.top);
}
pb.y += pl.rp.space_after.max(0.0);
}
/// How far a paragraph with mirrored indents (`w:mirrorIndents`) moves right on page `number`:
/// its left and right indents are the inside and outside ones, which swap sides on even pages.
fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: bool) -> f32 {
if !rp.mirror_indents || web || !number.is_multiple_of(2) {
return 0.0;
}
let (left, right) = rp.visual_indents();
right - left
}
/// Height of the first line of the block after `block` (for keep-with-next).
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
match ctx.doc.body.get(block + 1).map(|b| &**b) {
Some(Block::Para(p)) => {
// Don't advance list counters for a lookahead: lay out without the label.
let rp = ctx.doc.styles.resolve_para(&p.props);
let env = para::ParaEnv {
doc: ctx.doc,
width,
label: None,
fields: &ctx.fields,
show_hidden: ctx.opts.show_hidden,
field_codes: ctx.opts.field_codes,
hide_deleted: ctx.opts.hides_deleted(StoryRef::Body),
table: None,
proofing: None,
exclusions: &[],
eq_number: ctx.eq_count,
grid: ctx.grid,
};
let _ = rp;
let pl = para::layout_para(p, &env);
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
}
Some(Block::Table(_)) => 24.0,
None => 0.0,
}
}
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
pb.prev = None;
let width = pb.col_w();
let mut tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1);
if let Some(f) = t.props.float.filter(|_| !pb.web) {
// Before Word 2013 layout (compatibility mode 15) an offset places the first cell's text,
// so the edge sits a cell margin further out.
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
let h: f32 = tl.rows.iter().map(|r| r.height).sum();
if h <= pb.uncapped_bottom() - pb.top + 0.01 {
place_floating_table(pb, &tl, &f, legacy, block, body_top);
return;
}
// Too tall for a page: Word lets it run across pages like a table in the text, from its
// own left edge.
tl.x = float_table_x(pb, tl.width, &f, legacy) - pb.col_x();
}
// A table in the text flow starts below the floating objects in its way.
below_floats(pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
let header_rows: Vec<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);
table_notes(ctx, pb, &a.items);
rest = Some(b);
}
// Like Word, a row that does not fit on an empty page, or under the header rows
// repeated there, is placed anyway and runs into the bottom margin.
None if pb.at_top() || fresh => break,
None => {}
}
pb.advance(block, body_top);
// Repeat header rows.
if !header_rows.contains(&ri) {
for hr in &header_rows {
if let Some(h) = tl.rows.get(*hr) {
place(pb, h);
}
}
}
fresh = true;
}
let last = rest.as_ref().unwrap_or(row);
place(pb, last);
table_notes(ctx, pb, &last.items);
}
}
/// Footnotes referenced in the cell lines of a placed table row (or part of one) go to the
/// bottom of the page, like those of body paragraphs; notes that don't fit there continue on
/// the next page (see [`PageBuilder::flush_notes`]).
fn table_notes(ctx: &mut Ctx, pb: &mut PageBuilder, items: &[Placed]) {
if pb.web {
return;
}
let mut ids: Vec<u32> = Vec::new();
for it in items {
let Placed::Lines { story: StoryRef::Body, para, l0, l1, .. } = it else { continue };
let (Some(a), Some(b)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) else { continue };
for (ci, id) in &para.notes {
if *ci >= a.c0 && *ci < b.c1 && !ids.contains(id) && !pb.notes.iter().any(|n| n.id == *id) {
ids.push(*id);
}
}
}
let before = pb.notes_h();
for id in ids {
let Some(part) = ctx.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) else { continue };
let blocks = part.blocks.clone();
let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.sect.text_width(), None, 0, None);
pb.notes.push(NoteBox { id, items, h, cont: false });
}
pb.bottom -= pb.notes_h() - before;
}
/// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text
/// flow; the text after it wraps around it.
fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) {
let h: f32 = tl.rows.iter().map(|r| r.height).sum::<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);
// Like Word, a table that doesn't fit below the text before it moves to the next page with
// its anchor: one placed relative to the paragraph when it would run off the page, one
// placed on the page or margin when it would also cover the text already on the page.
let moves = if f.v_rel == Anchor::Paragraph {
r.bottom() > pb.bottom + 0.01
} else {
let covers_text = r.y < pb.y - 0.01 && r.bottom() > pb.top + 0.01 && r.x < pb.col_x() + pb.col_w() && r.right() > pb.col_x();
covers_text && pb.y + h > pb.bottom + 0.01
};
if attempt == 0 && moves && !pb.at_top() {
pb.advance(block, body_top);
continue;
}
if let Some(pg) = pb.page() {
let mut y = r.y;
for row in &tl.rows {
for it in &row.items {
let mut it = it.clone();
it.translate(r.x, y);
pg.items.push(it);
}
y += row.height;
}
}
let area = r.inset(-dl, -dt, -dr, -db);
pb.excl.push((area, false, None, WrapText::BothSides));
pb.float_tables.push(area);
return;
}
}
/// The area a floating table's `x` is measured in: its start and width on the page.
fn float_table_h_area(pb: &PageBuilder, f: &TableFloat) -> (f32, f32) {
let s = pb.sect;
match f.h_rel {
Anchor::Page => (0.0, s.page_w),
Anchor::Margin => (s.margin_left + s.gutter, s.text_width()),
_ => (pb.col_x(), pb.col_w()),
}
}
/// A floating table's left edge on the page (`legacy`: the first cell's left margin before Word
/// 2013 layout, when an offset places the cell's text).
fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32) -> f32 {
let x = align_in(float_table_h_area(pb, f), f.x, w, f.h_align);
if f.h_align.is_none() { x - legacy } else { x }
}
/// Move `pb.y` below the floating objects in the way of something spanning `x0..x1` that
/// starts there (a table in the text flow doesn't wrap around them).
fn below_floats(pb: &mut PageBuilder, x0: f32, x1: f32) {
pb.y = below(&pb.excl, pb.y, x0, x1);
}
/// `y`, moved below the wrap areas in `excl` in the way of something spanning `x0..x1` that
/// starts there.
fn below(excl: &[WrapArea], mut y: f32, x0: f32, x1: f32) -> f32 {
for _ in 0..64 {
let in_the_way = excl.iter().filter(|(r, ..)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0);
let Some(bottom) = in_the_way.map(|(r, ..)| r.bottom()).reduce(f32::max) else { break };
y = bottom;
}
y
}
/// Section `si`'s headers (or footers, as `get` picks), with those it leaves out inherited from
/// the sections before it (Word's "link to previous").
fn linked(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet) -> HeaderSet {
let mut set = sections.get(si).map(|(_, s)| get(s)).unwrap_or_default();
for (_, s) in sections.iter().take(si).rev() {
if set.default.is_some() && set.first.is_some() && set.even.is_some() {
break;
}
let o = get(s);
set.default = set.default.or(o.default);
set.first = set.first.or(o.first);
set.even = set.even.or(o.even);
}
set
}
fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) {
let total = pages.len() as u32;
// Pages per section.
let mut per_section: HashMap<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: fn(&SectionProps) -> HeaderSet| -> Option<u32> {
let set = linked(sections, page.section, get);
if sect.title_page && first_of_section.contains(&i) {
return set.first;
}
if even_odd && page.number % 2 == 0 {
return set.even.or(set.default);
}
set.default
};
let hid = pick(|s| s.headers);
let fid = pick(|s| s.footers);
ctx.fields.page = page.number;
ctx.fields.page_format = sect.page_num_format;
ctx.fields.pages = total;
ctx.fields.section_pages = per_section.get(&page.section).copied().unwrap_or(1);
ctx.fields.section = page.section as u32 + 1;
let w = sect.text_width();
let x = sect.margin_left + sect.gutter;
if let Some(id) = hid
&& let Some(part) = ctx.doc.parts.get(&id)
{
let blocks = part.blocks.clone();
let frame = PageFrame { sect, origin: (x, sect.header) };
let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items;
for it in &mut items {
it.translate(x, sect.header);
}
page.header = items;
page.header_story = Some(id);
}
if let Some(id) = fid
&& let Some(part) = ctx.doc.parts.get(&id)
{
let blocks = part.blocks.clone();
// The footer's height fixes where it sits, which page-relative floats need.
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, None).height;
let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h) };
let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items;
for it in &mut items {
it.translate(x, sect.page_h - sect.footer - h);
}
page.footer = items;
page.footer_story = Some(id);
}
}
}
/// Mirrored margins: even pages have the inside margin (and gutter) on the right, so their
/// header, text, notes and footer move to start at the outside margin. Page borders, measured
/// from the page edge, stay.
fn mirror_even_pages(pages: &mut [Page], sections: &[(usize, &SectionProps)]) {
for page in pages.iter_mut().filter(|p| p.number % 2 == 0) {
let Some((_, s)) = sections.get(page.section) else { continue };
let dx = s.margin_right - s.margin_left - s.gutter;
if dx.abs() < 0.001 || !dx.is_finite() {
continue;
}
let borders = page_border_rules(s).len();
for it in page.items.iter_mut().skip(borders).chain(page.header.iter_mut()).chain(page.footer.iter_mut()) {
it.translate(dx, 0.0);
}
page.body.x += dx;
}
}
/// Is an object floating (not laid out inline)?
pub fn is_floating(o: &InlineObject) -> bool {
o.is_floating()
}
/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web).
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
static T0: std::sync::OnceLock<std::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_complex;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_pages;
#[cfg(test)]
mod tests_textbox;
#[cfg(test)]
mod tests_vertical;