mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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>
3049 lines
129 KiB
Rust
3049 lines
129 KiB
Rust
//! 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<¶::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<¶::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, ¬e_pages, &totals, passes < MAX_TOTAL_PASSES) {
|
||
Pass::Again(t) => {
|
||
totals = t;
|
||
passes += 1;
|
||
}
|
||
Pass::Done(l) => {
|
||
if per_page && note_passes < MAX_NOTE_PASSES {
|
||
// Number each page's footnotes from where the pass before placed them, until they stay.
|
||
let placed = footnote_pages(doc, &l.pages);
|
||
if placed != note_pages {
|
||
note_pages = placed;
|
||
note_passes += 1;
|
||
continue;
|
||
}
|
||
}
|
||
break l;
|
||
}
|
||
}
|
||
};
|
||
l.ms = now_ms() - t0;
|
||
l
|
||
}
|
||
|
||
/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]),
|
||
/// with `totals` assumed for the page totals in the body. With `may_repeat`, a pass whose body
|
||
/// shows totals that turn out different asks to be done again.
|
||
fn layout_pass(
|
||
doc: &Document,
|
||
cache: &mut LayoutCache,
|
||
opts: &LayoutOptions,
|
||
note_pages: &HashMap<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(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers));
|
||
let body_top = if web || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
|
||
let first_top = if web || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) };
|
||
let foot_top = if web || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) };
|
||
let first_foot = if web || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) };
|
||
ctx.grid = para::Grid::of(doc, sect);
|
||
let restart = sect.page_num_start;
|
||
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
|
||
pb.line_no = 0;
|
||
}
|
||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||
if web && si > 0 {
|
||
// one long page
|
||
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
|
||
// A page holds text running one way: a section turning the other way starts a page.
|
||
if let Some(n) = restart {
|
||
pb.number = n.saturating_sub(1);
|
||
}
|
||
pb.foot_top = first_foot;
|
||
pb.new_page(block, first_top);
|
||
pb.foot_top = foot_top;
|
||
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
|
||
let want_even = start == SectionStart::EvenPage;
|
||
if pb.number.is_multiple_of(2) != want_even {
|
||
pb.new_page(block, body_top);
|
||
}
|
||
}
|
||
} else {
|
||
// Continuous: new column layout below the current text (its tallest column).
|
||
pb.cols = sect.column_boxes();
|
||
pb.col = 0;
|
||
pb.top = pb.text_bottom();
|
||
pb.y = pb.top;
|
||
pb.max_y = pb.top;
|
||
}
|
||
prev_turn = turn;
|
||
pb.foot_top = foot_top;
|
||
let last = (*end).min(doc.body.len().saturating_sub(1));
|
||
// Newspaper columns are balanced on the section's last page when the next section starts
|
||
// on the same page (a continuous break), as in Word. That page is laid out again from the
|
||
// start of the block that reaches it, with shorter columns (see `balance_columns`).
|
||
let balance = !web
|
||
&& pb.cols.len() > 1
|
||
&& sect.valign == wordcraft_doc::props::VAlign::Top
|
||
&& sections.get(si + 1).is_some_and(|(_, s)| s.start == SectionStart::Continuous);
|
||
pb.col_break_page = None;
|
||
let mut from = balance.then(|| (block, Snap::take(&mut pb, &ctx)));
|
||
while block <= last {
|
||
let Some(b) = doc.body.get(block) else { break };
|
||
let before = balance.then(|| (pb.pages.len(), Snap::take(&mut pb, &ctx)));
|
||
match (&**b, &outline) {
|
||
(Block::Para(p), Some(o)) => place_outline_para(&mut ctx, &mut pb, p, block, o),
|
||
(Block::Table(_), Some(o)) if o.hidden.get(block).copied().unwrap_or(false) => {}
|
||
(Block::Para(p), None) => place_para(&mut ctx, &mut pb, p, block, body_top),
|
||
(Block::Table(t), _) => place_table(&mut ctx, &mut pb, t, block, body_top),
|
||
}
|
||
if let Some((pages, snap)) = before
|
||
&& pb.pages.len() != pages
|
||
{
|
||
from = Some((block, snap));
|
||
}
|
||
block += 1;
|
||
}
|
||
if let Some((first, snap)) = from {
|
||
boxes_discarded += balance_columns(&mut ctx, &mut pb, (first, block), &snap, body_top);
|
||
}
|
||
// Endnotes at the end of the section: those referenced so far.
|
||
if end_opts.pos == wordcraft_doc::section::NotePos::SectEnd && !pb.pages.is_empty() {
|
||
let mut here = Vec::new();
|
||
while let Some((id, _)) = endnotes.front().filter(|(_, s)| *s <= si).copied() {
|
||
endnotes.pop_front();
|
||
here.push(id);
|
||
}
|
||
place_endnotes(&mut ctx, &mut pb, here, block.saturating_sub(1));
|
||
}
|
||
}
|
||
if pb.pages.is_empty() {
|
||
pb.new_page(0, sect_ref.margin_top);
|
||
}
|
||
// The rest of the endnotes after the last paragraph.
|
||
let rest: Vec<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, §ions);
|
||
if doc.settings.mirror_margins {
|
||
mirror_even_pages(&mut pages, §ions);
|
||
}
|
||
}
|
||
// Evict paragraph layouts that weren't used this pass if the cache grew large.
|
||
if ctx.cache.paras.len() > 4 * ctx.cache.used.len().max(1024) {
|
||
let used = std::mem::take(&mut ctx.cache.used);
|
||
ctx.cache.paras.retain(|k, _| used.contains(k));
|
||
ctx.cache.used = used;
|
||
}
|
||
let mut index: HashMap<(StoryRef, Path), Vec<(usize, usize)>> = HashMap::new();
|
||
for (pi, p) in pages.iter().enumerate() {
|
||
for (ii, it) in p.items.iter().enumerate() {
|
||
if let Placed::Lines { story, path, .. } = it {
|
||
index.entry((*story, path.clone())).or_default().push((pi, ii));
|
||
}
|
||
}
|
||
}
|
||
let mut lay = DocLayout { pages, index, ms: 0.0, text_boxes: 0 };
|
||
if opts.comment_balloons && !web {
|
||
balloons::add(&mut ctx, &mut lay);
|
||
}
|
||
lay.text_boxes = ctx.boxes.total().saturating_sub(boxes_discarded);
|
||
Pass::Done(lay)
|
||
}
|
||
|
||
/// Pagination state saved before a block, to lay the blocks from there out again (balancing
|
||
/// columns): the builder without its pages, how far its last page had got, and the walk state.
|
||
struct Snap<'a> {
|
||
pb: PageBuilder<'a>,
|
||
pages: usize,
|
||
items: usize,
|
||
behind: usize,
|
||
counters: Counters,
|
||
eq_count: u32,
|
||
page_field: u32,
|
||
boxes: usize,
|
||
}
|
||
|
||
impl<'a> Snap<'a> {
|
||
fn take(pb: &mut PageBuilder<'a>, ctx: &Ctx) -> Self {
|
||
// The pages stay out of the copy: it would cost the whole document per block.
|
||
let (pages, behind) = (std::mem::take(&mut pb.pages), std::mem::take(&mut pb.behind));
|
||
let copy = pb.clone();
|
||
(pb.pages, pb.behind) = (pages, behind);
|
||
Snap {
|
||
pb: copy,
|
||
pages: pb.pages.len(),
|
||
items: pb.pages.last().map_or(0, |p| p.items.len()),
|
||
behind: pb.behind.last().copied().unwrap_or(0),
|
||
counters: ctx.counters.clone(),
|
||
eq_count: ctx.eq_count,
|
||
page_field: ctx.fields.page,
|
||
boxes: ctx.boxes.total(),
|
||
}
|
||
}
|
||
|
||
/// Go back to this state: drop the pages added since and what was added to the last one
|
||
/// (appended, or inserted at the front behind the text).
|
||
fn restore(&self, pb: &mut PageBuilder<'a>, ctx: &mut Ctx) {
|
||
let (mut pages, mut behind) = (std::mem::take(&mut pb.pages), std::mem::take(&mut pb.behind));
|
||
pages.truncate(self.pages);
|
||
behind.truncate(self.pages);
|
||
if let Some(pg) = pages.last_mut() {
|
||
let k = behind.last().copied().unwrap_or(0).saturating_sub(self.behind).min(pg.items.len());
|
||
pg.items.drain(..k);
|
||
pg.items.truncate(self.items);
|
||
}
|
||
if let Some(b) = behind.last_mut() {
|
||
*b = self.behind;
|
||
}
|
||
*pb = self.pb.clone();
|
||
(pb.pages, pb.behind) = (pages, behind);
|
||
ctx.counters = self.counters.clone();
|
||
ctx.eq_count = self.eq_count;
|
||
ctx.fields.page = self.page_field;
|
||
}
|
||
}
|
||
|
||
/// One balancing pass: blocks `first..end` laid out again from `snap` with the columns on `page`
|
||
/// ending `h` below `top`; true if the page's text fits them.
|
||
fn balance_pass<'a>(
|
||
ctx: &mut Ctx,
|
||
pb: &mut PageBuilder<'a>,
|
||
snap: &Snap<'a>,
|
||
(first, end): (usize, usize),
|
||
(page, top, h): (usize, f32, f32),
|
||
body_top: f32,
|
||
) -> bool {
|
||
snap.restore(pb, ctx);
|
||
pb.balance = Some((page, h));
|
||
pb.cap_columns();
|
||
for block in first..end {
|
||
match ctx.doc.body.get(block).map(|b| &**b) {
|
||
Some(Block::Para(p)) => place_para(ctx, pb, p, block, body_top),
|
||
Some(Block::Table(t)) => place_table(ctx, pb, t, block, body_top),
|
||
None => break,
|
||
}
|
||
// Spilled onto another page: too short (and no need to lay out the rest).
|
||
if pb.pages.len() > page + 1 {
|
||
break;
|
||
}
|
||
}
|
||
pb.balance = None;
|
||
pb.cap_columns();
|
||
pb.pages.len() == page + 1 && pb.content_bottom <= top + h + 0.5
|
||
}
|
||
|
||
/// Balancing lays out at most this many pages again per pass (a block reaching the last page
|
||
/// from further back is left unbalanced), so its cost stays a small multiple of one page.
|
||
const BALANCE_MAX_PAGES: usize = 16;
|
||
/// Passes of the binary search on the column height (enough for well under a point).
|
||
const BALANCE_PASSES: usize = 12;
|
||
|
||
/// Balance the columns on the last page of a section followed by a continuous break, as Word
|
||
/// does: lay blocks `first..end` out again from `snap` (taken before `first`) with the shortest
|
||
/// columns that still hold that page's text — a binary search on the column height, where a
|
||
/// height fits when nothing moves to another page and nothing (an unsplittable row, a line
|
||
/// taller than the column) runs past it. Keep-together, keep-with-next and widow/orphan control
|
||
/// apply as usual, so the columns may come out a little uneven. Not balanced: a page with an
|
||
/// explicit column break, which says where the columns end. Returns the text boxes laid out in the
|
||
/// passes thrown away.
|
||
fn balance_columns<'a>(ctx: &mut Ctx, pb: &mut PageBuilder<'a>, blocks: (usize, usize), snap: &Snap<'a>, body_top: f32) -> usize {
|
||
let page = pb.pages.len().saturating_sub(1);
|
||
let ncols = pb.cols.len().max(1) as f32;
|
||
let top = pb.top;
|
||
let full = pb.uncapped_bottom() - top;
|
||
let used = pb.content_bottom.min(pb.uncapped_bottom()) - top;
|
||
if pb.col_break_page == Some(page)
|
||
|| snap.pages == 0
|
||
|| (page + 1).saturating_sub(snap.pages) > BALANCE_MAX_PAGES
|
||
|| used <= 1.0
|
||
|| full <= 1.0
|
||
|| pb.cols.len() < 2
|
||
{
|
||
return 0;
|
||
}
|
||
let pass = |ctx: &mut Ctx, pb: &mut PageBuilder<'a>, h: f32| balance_pass(ctx, pb, snap, blocks, (page, top, h), body_top);
|
||
// The text on the page fills `ncols` columns at least `used / ncols` high; the original layout
|
||
// (`full`) fits, and so, nearly always, do columns as high as its tallest one.
|
||
let mut lo = (used / ncols - 1.0).max(0.0);
|
||
let mut hi = full;
|
||
let mut last = None;
|
||
let discard = |ctx: &Ctx| ctx.boxes.total().saturating_sub(snap.boxes);
|
||
let mut thrown = discard(ctx);
|
||
if used < full - 0.5 {
|
||
let ok = pass(ctx, pb, used);
|
||
if ok {
|
||
hi = used;
|
||
}
|
||
last = Some((used, ok));
|
||
}
|
||
for _ in 0..BALANCE_PASSES {
|
||
if hi - lo <= 1.0 {
|
||
break;
|
||
}
|
||
let mid = (lo + hi) / 2.0;
|
||
thrown = discard(ctx);
|
||
let ok = pass(ctx, pb, mid);
|
||
if ok {
|
||
hi = mid;
|
||
} else {
|
||
lo = mid;
|
||
}
|
||
last = Some((mid, ok));
|
||
}
|
||
// End on the best height (the full column, as laid out first, when none was shorter).
|
||
if last != Some((hi, true)) {
|
||
thrown = discard(ctx);
|
||
if !pass(ctx, pb, hi) && hi < full {
|
||
thrown = discard(ctx);
|
||
pass(ctx, pb, full);
|
||
}
|
||
}
|
||
thrown
|
||
}
|
||
|
||
/// Place endnotes `ids` below the text so far: a separator (the document's own, else a short
|
||
/// line), then each note, moving to the next column or page when one doesn't fit.
|
||
fn place_endnotes(ctx: &mut Ctx, pb: &mut PageBuilder, ids: Vec<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 ¬es {
|
||
if *ci >= l.c0
|
||
&& *ci < l.c1
|
||
&& !pb.notes.iter().chain(new_notes.iter()).any(|x| x.id == *id)
|
||
&& let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote)
|
||
{
|
||
let blocks = part.blocks.clone();
|
||
let BoxLayout { items, height: h, .. } =
|
||
layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0, None);
|
||
line_notes.push(NoteBox { id: *id, items, h, cont: false });
|
||
}
|
||
}
|
||
}
|
||
let extra: f32 = line_notes.iter().map(|n| n.h).sum::<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 ¶.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;
|