mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
The original squash merges of these six PRs were empty. Integrated and tested together on current main. Co-authored-by: Valentin Klein <vangel1976@googlemail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1841 lines
75 KiB
Rust
1841 lines
75 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 display;
|
|
pub mod fields;
|
|
pub mod hit;
|
|
pub mod kinsoku;
|
|
pub mod math;
|
|
pub mod para;
|
|
mod table;
|
|
|
|
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};
|
|
use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat, TextDirection};
|
|
use wordcraft_doc::section::{SectionProps, SectionStart};
|
|
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef};
|
|
use wordcraft_geom::{Point, Rect};
|
|
|
|
pub use fields::FieldCtx;
|
|
pub use hit::VisualStep;
|
|
pub use para::{LineEnd, ParaLayout};
|
|
pub use table::{autofit_widths, measure_table_columns};
|
|
|
|
/// How the document is viewed.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum ViewMode {
|
|
#[default]
|
|
Print,
|
|
/// One page as wide as the window, no page breaks.
|
|
Web,
|
|
/// Draft/Outline: like web, page breaks shown as rules.
|
|
Draft,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct LayoutOptions {
|
|
pub view: ViewMode,
|
|
/// Width of the window in Web/Draft views, points.
|
|
pub web_width: f32,
|
|
pub show_hidden: bool,
|
|
/// Final text ("No Markup"): tracked deletions take no space and draw nothing.
|
|
pub hide_deleted: bool,
|
|
/// Check spelling and grammar (squiggles).
|
|
pub proofing: bool,
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
Image {
|
|
rect: Rect,
|
|
media: String,
|
|
crop: [f32; 4],
|
|
story: StoryRef,
|
|
path: Path,
|
|
off: usize,
|
|
},
|
|
Shape {
|
|
rect: Rect,
|
|
kind: wordcraft_doc::para::ShapeKind,
|
|
fill: Option<Rgb>,
|
|
stroke: Option<Rgb>,
|
|
stroke_width: f32,
|
|
effects: wordcraft_doc::effects::ShapeEffects,
|
|
},
|
|
/// 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,
|
|
},
|
|
/// A table cell's area (for hit testing and cell selection).
|
|
Cell {
|
|
rect: Rect,
|
|
table: Path,
|
|
row: usize,
|
|
cell: usize,
|
|
story: StoryRef,
|
|
},
|
|
/// The area of a picture, shape or text box, for hit testing, selection handles and dragging
|
|
/// (drawn by `Image`/`Shape`/`Lines`). The object is the U+FFFC at byte `off` of paragraph
|
|
/// `path` in `story`.
|
|
Object {
|
|
rect: Rect,
|
|
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::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::Rule { x0, y0, x1, y1, .. } => {
|
|
(*x0, *y0) = turn_point(turn, x, y, *x0, *y0);
|
|
(*x1, *y1) = turn_point(turn, x, y, *x1, *y1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where point (`u`, `v`) of a frame turned `turn` lands on the page, the frame's origin being
|
|
/// page point (`x`, `y`): `u` runs along the lines, `v` across them (down for horizontal text).
|
|
pub fn turn_point(turn: TextDirection, x: f32, y: f32, u: f32, v: f32) -> (f32, f32) {
|
|
match turn {
|
|
TextDirection::Horizontal => (x + u, y + v),
|
|
TextDirection::Down => (x - v, y + u),
|
|
TextDirection::Up => (x + v, y - u),
|
|
}
|
|
}
|
|
|
|
/// The inverse of [`turn_point`]: the frame point at page point (`px`, `py`).
|
|
pub fn unturn_point(turn: TextDirection, x: f32, y: f32, px: f32, py: f32) -> (f32, f32) {
|
|
match turn {
|
|
TextDirection::Horizontal => (px - x, py - y),
|
|
TextDirection::Down => (py - y, x - px),
|
|
TextDirection::Up => (y - py, px - x),
|
|
}
|
|
}
|
|
|
|
/// The page rectangle a frame rectangle covers (see [`turn_point`]).
|
|
pub fn turn_rect(turn: TextDirection, x: f32, y: f32, r: Rect) -> Rect {
|
|
match turn {
|
|
TextDirection::Horizontal => Rect::new(x + r.x, y + r.y, r.w, r.h),
|
|
TextDirection::Down => Rect::new(x - r.y - r.h, y + r.x, r.h, r.w),
|
|
TextDirection::Up => Rect::new(x + r.y, y - r.x - r.w, r.h, r.w),
|
|
}
|
|
}
|
|
|
|
/// One laid-out page.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Page {
|
|
pub w: f32,
|
|
pub h: f32,
|
|
/// Index into `Document::sections()`.
|
|
pub section: usize,
|
|
/// Page number as displayed (after section restarts).
|
|
pub number: u32,
|
|
pub items: Vec<Placed>,
|
|
/// Header / footer items (drawn dimmed while editing the body).
|
|
pub header: Vec<Placed>,
|
|
pub footer: Vec<Placed>,
|
|
/// The body text area (margins).
|
|
pub body: Rect,
|
|
pub header_story: Option<u32>,
|
|
pub footer_story: Option<u32>,
|
|
/// Top-level body block index at the start of the page.
|
|
pub first_block: usize,
|
|
}
|
|
|
|
/// The whole layout.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct DocLayout {
|
|
pub pages: Vec<Page>,
|
|
/// Index: (story, paragraph path) → (page, item index) of each placed piece, in order.
|
|
pub index: HashMap<(StoryRef, Path), Vec<(usize, usize)>>,
|
|
/// Layout time, milliseconds.
|
|
pub ms: f64,
|
|
/// Text box stories laid out (each time a box's text was).
|
|
pub text_boxes: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
struct Key {
|
|
rev: u64,
|
|
width: u32,
|
|
label: Option<String>,
|
|
page: Option<(u32, u32, u32, u32)>,
|
|
table: u64,
|
|
notes: u64,
|
|
excl: u64,
|
|
eq: u32,
|
|
}
|
|
|
|
/// Memoised paragraph layouts.
|
|
#[derive(Default)]
|
|
pub struct LayoutCache {
|
|
paras: HashMap<Key, Arc<ParaLayout>>,
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn hash_of<T: Hash>(t: &T) -> u64 {
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
t.hash(&mut h);
|
|
h.finish()
|
|
}
|
|
|
|
fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
|
let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default();
|
|
hash_of(&(s, opts.show_hidden, opts.hide_deleted, opts.proofing, wordcraft_proof::user_dictionary().len(), doc.settings.auto_hyphenation))
|
|
}
|
|
|
|
fn has_page_fields(p: &Paragraph) -> bool {
|
|
p.objects.iter().any(|o| match o {
|
|
InlineObject::Field { instr, .. } => {
|
|
matches!(fields::field_name(instr).as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION")
|
|
}
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
/// Walk state while paginating.
|
|
struct Ctx<'a> {
|
|
doc: &'a Document,
|
|
cache: &'a mut LayoutCache,
|
|
opts: &'a LayoutOptions,
|
|
counters: Counters,
|
|
fields: FieldCtx,
|
|
notes_hash: u64,
|
|
numbers: HashMap<u32, Arc<ParaLayout>>,
|
|
/// Automatic equation numbers so far.
|
|
eq_count: u32,
|
|
/// Bounds laying out text boxes inside text boxes, for the whole layout.
|
|
boxes: wordcraft_doc::BoxBudget,
|
|
}
|
|
|
|
impl Ctx<'_> {
|
|
/// 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,
|
|
hide_deleted: false,
|
|
table: None,
|
|
proofing: false,
|
|
exclusions: &[],
|
|
eq_number: 0,
|
|
};
|
|
let pl = Arc::new(para::layout_para(&p, &env));
|
|
self.numbers.insert(n, pl.clone());
|
|
pl
|
|
}
|
|
|
|
/// The list label of `p`, advancing the list counters: call it once per paragraph, in
|
|
/// document order (only for paragraphs that will show).
|
|
fn next_label(&mut self, p: &Paragraph) -> Option<(String, Level)> {
|
|
let n = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?;
|
|
self.counters.next_label(&self.doc.numbering, n.num, n.level)
|
|
}
|
|
|
|
/// Lay out `p` with its already counted `label`, so laying a paragraph out again (around
|
|
/// floating objects, or after it moved) doesn't advance the list counters twice.
|
|
fn para_labelled(
|
|
&mut self,
|
|
p: &Paragraph,
|
|
width: f32,
|
|
table: Option<¶::CellText>,
|
|
exclusions: &[para::Exclusion],
|
|
label: Option<(String, Level)>,
|
|
) -> Arc<ParaLayout> {
|
|
let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None };
|
|
// Automatic equation numbers count through the document.
|
|
let eq_here: u32 = p
|
|
.objects
|
|
.iter()
|
|
.map(|o| match o {
|
|
InlineObject::Equation { math, display, .. } => math::auto_numbers(math, *display),
|
|
_ => 0,
|
|
})
|
|
.sum();
|
|
let eq_number = self.eq_count;
|
|
self.eq_count = self.eq_count.saturating_add(eq_here);
|
|
let key = Key {
|
|
rev: p.rev,
|
|
width: width.to_bits(),
|
|
label: label.as_ref().map(|(t, l)| format!("{t}|{}|{}|{:?}", l.indent, l.hanging, l.suffix)),
|
|
page,
|
|
table: table.map(|t| hash_of(&format!("{t:?}"))).unwrap_or(0),
|
|
notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 },
|
|
excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) },
|
|
eq: if eq_here > 0 { eq_number } else { 0 },
|
|
};
|
|
self.cache.used.insert(key.clone());
|
|
if let Some(pl) = self.cache.paras.get(&key) {
|
|
self.cache.hits += 1;
|
|
return pl.clone();
|
|
}
|
|
self.cache.misses += 1;
|
|
let env = para::ParaEnv {
|
|
doc: self.doc,
|
|
width,
|
|
label,
|
|
fields: &self.fields,
|
|
show_hidden: self.opts.show_hidden,
|
|
hide_deleted: self.opts.hide_deleted,
|
|
table,
|
|
proofing: self.opts.proofing,
|
|
exclusions,
|
|
eq_number,
|
|
};
|
|
let pl = Arc::new(para::layout_para(p, &env));
|
|
self.cache.paras.insert(key, pl.clone());
|
|
pl
|
|
}
|
|
}
|
|
|
|
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
|
|
/// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the
|
|
/// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is
|
|
/// laid out with (`StyleSheet::resolve_char_in`).
|
|
fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool {
|
|
let mut left = Vec::new();
|
|
// Nothing can be left out with hidden text shown and markup on.
|
|
if (hide_deleted || !show_hidden) && !p.objects.is_empty() {
|
|
let style = p.props.style.as_deref();
|
|
let is_left = |c: &CharProps| {
|
|
let rc = doc.styles.resolve_char_in(style, table_chr, c);
|
|
para::left_out(&rc, show_hidden, hide_deleted)
|
|
};
|
|
// Runs and objects are both in order: one walk finds each object's run (past the last run,
|
|
// the paragraph mark, as `props_of_char` gives), resolving a run's formatting only once.
|
|
let mut runs = p.run_ranges().peekable();
|
|
let mut last: Option<(usize, bool)> = None;
|
|
for off in p.object_offsets() {
|
|
while runs.next_if(|(r, _)| r.end <= off).is_some() {}
|
|
let l = match runs.peek() {
|
|
Some((r, c)) => match last {
|
|
Some((end, l)) if end == r.end => l,
|
|
_ => {
|
|
let l = is_left(c);
|
|
last = Some((r.end, l));
|
|
l
|
|
}
|
|
},
|
|
None => is_left(&p.mark),
|
|
};
|
|
left.push(l);
|
|
}
|
|
}
|
|
move |k| left.get(k).copied().unwrap_or(false)
|
|
}
|
|
|
|
/// Note part ids in document order → numbers (footnotes and endnotes numbered separately). With
|
|
/// `hide_deleted`, notes whose reference mark is a tracked deletion are left out, as in the final text.
|
|
fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap<u32, u32> {
|
|
let mut m = HashMap::new();
|
|
let (mut f, mut e) = (0u32, 0u32);
|
|
// 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_at(&mut |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) {
|
|
let n = match kind {
|
|
wordcraft_doc::para::NoteKind::Footnote => {
|
|
f += 1;
|
|
f
|
|
}
|
|
wordcraft_doc::para::NoteKind::Endnote => {
|
|
e += 1;
|
|
e
|
|
}
|
|
};
|
|
m.insert(*id, n);
|
|
}
|
|
true
|
|
});
|
|
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 {
|
|
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<(Rect, bool)> = Vec::new();
|
|
// Areas of the box's floating tables, which other floating tables may have to avoid.
|
|
let mut float_tables: Vec<Rect> = Vec::new();
|
|
let mut y = 0.0f32;
|
|
let mut prev_after = 0.0f32;
|
|
let mut prev_style: Option<(String, bool)> = None;
|
|
for (i, b) in blocks.iter().enumerate() {
|
|
let mut path = prefix.to_vec();
|
|
path.push(i as u32);
|
|
match &**b {
|
|
Block::Para(p) => {
|
|
let label = ctx.next_label(p);
|
|
let mut pl = ctx.para_labelled(p, width, table, &[], label.clone());
|
|
let ctxl = pl.rp.contextual_spacing;
|
|
let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl));
|
|
let before = if same && ctxl { 0.0 } else { pl.rp.space_before };
|
|
if same && ctxl {
|
|
y -= prev_after;
|
|
}
|
|
// Word: space between paragraphs is before + after (no collapsing).
|
|
y += if i == 0 { before } else { before.max(0.0) };
|
|
// Floating objects anchored here: place them, then wrap the text around them.
|
|
// One left out of the layout (hidden, or deleted in the final text) takes no room.
|
|
let mut floats = HashMap::new();
|
|
let left_out = left_out_objects(ctx.doc, p, table.map(|t| &t.chr), ctx.opts.show_hidden, ctx.opts.hide_deleted);
|
|
for (oi, o) in p.objects.iter().enumerate() {
|
|
let Some((w, h, float)) = floating(o) else { continue };
|
|
if left_out(oi) {
|
|
continue;
|
|
}
|
|
let r = float_rect(frame, (0.0, width), y, w, h, float);
|
|
excl.extend(wrap_area(r, float));
|
|
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));
|
|
float_tables.push(area);
|
|
(r.x, r.y)
|
|
}
|
|
// A table in the text flow starts below the floating objects in its way.
|
|
None => (tl.x, below(&excl, y, tl.x, tl.x + tl.width)),
|
|
};
|
|
let mut row_y = ty;
|
|
for row in &tl.rows {
|
|
for it in &row.items {
|
|
let mut it = it.clone();
|
|
it.translate(tx, row_y);
|
|
items.push(it);
|
|
}
|
|
row_y += row.height;
|
|
}
|
|
if t.props.float.is_none() {
|
|
y = row_y;
|
|
prev_after = 0.0;
|
|
prev_style = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
|
|
BoxLayout { items, height: y.max(0.0), floats_bottom }
|
|
}
|
|
|
|
/// A laid-out box: items relative to its top-left, the height of its text flow, and where its
|
|
/// floating tables end (a table cell grows to hold them).
|
|
struct BoxLayout {
|
|
items: Vec<Placed>,
|
|
height: f32,
|
|
floats_bottom: f32,
|
|
}
|
|
|
|
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
|
|
/// the area text keeps clear of. Positions relative to the page or its margins need the box's
|
|
/// `frame`; without one (a table cell) they're relative to the box.
|
|
fn box_float_table_rect(
|
|
frame: Option<PageFrame>,
|
|
width: f32,
|
|
y: f32,
|
|
tl: &table::TableLayout,
|
|
f: &TableFloat,
|
|
legacy: f32,
|
|
float_tables: &[Rect],
|
|
) -> (Rect, Rect) {
|
|
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
|
let w = tl.width.clamp(1.0, 100_000.0);
|
|
let page_x = |s: &SectionProps| match f.h_rel {
|
|
Anchor::Page => Some((0.0, s.page_w)),
|
|
Anchor::Margin => Some((s.margin_left + s.gutter, s.text_width())),
|
|
_ => None,
|
|
};
|
|
let page_y = |s: &SectionProps| match f.v_rel {
|
|
Anchor::Page => Some((0.0, s.page_h)),
|
|
Anchor::Margin => Some((s.margin_top, s.text_height())),
|
|
_ => None,
|
|
};
|
|
let h_area = frame.and_then(|fr| page_x(fr.sect).map(|(a, e)| (a - fr.origin.0, e))).unwrap_or((0.0, width));
|
|
// In a cell an offset from the page or margin has nothing to measure from: the table stands
|
|
// where the text is.
|
|
let (v_area, y_off) = match frame.and_then(|fr| page_y(fr.sect).map(|(a, e)| (a - fr.origin.1, e))) {
|
|
Some(area) => (area, f.y),
|
|
None if matches!(f.v_rel, Anchor::Page | Anchor::Margin) => ((y, 0.0), 0.0),
|
|
None => ((y, 0.0), f.y),
|
|
};
|
|
// Nor is there an area to align in: the table starts where the text is.
|
|
let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align };
|
|
let x = align_in(h_area, f.x, w, f.h_align) - if f.h_align.is_none() { legacy } else { 0.0 };
|
|
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables);
|
|
let [dl, dt, dr, db] = f.dist_from_text();
|
|
(r, r.inset(-dl, -dt, -dr, -db))
|
|
}
|
|
|
|
/// Floating table `f` at `r`, moved below the floating tables in its way when it may not
|
|
/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's
|
|
/// left edge (`text_left`).
|
|
fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect {
|
|
let [dl, dt, dr, db] = f.dist_from_text();
|
|
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
|
for _ in 0..64 {
|
|
let area = r.inset(-dl, -dt, -dr, -db);
|
|
let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break };
|
|
r.y = below + dt;
|
|
if keeps_text_distance {
|
|
r.x = r.x.max(text_left + dl);
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
/// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom).
|
|
const BOX_INSET_X: f32 = 7.2;
|
|
const BOX_INSET_Y: f32 = 3.6;
|
|
|
|
/// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are
|
|
/// dropped (whole lines; a line that only partly fits too) and so is anything starting below.
|
|
/// The first line always stays, so even a tiny box shows the start of its text.
|
|
fn fit_box(items: Vec<Placed>, height: f32) -> Vec<Placed> {
|
|
let limit = height + 0.5;
|
|
let mut out = Vec::with_capacity(items.len());
|
|
let mut kept_line = false;
|
|
for it in items {
|
|
if let Some(r) = it.turned_bounds() {
|
|
// Turned text (in a table cell) stays whole.
|
|
if r.y < limit {
|
|
out.push(it);
|
|
}
|
|
continue;
|
|
}
|
|
match it {
|
|
Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
|
|
let Some(first) = para.lines.get(l0) else { continue };
|
|
let mut end = l0;
|
|
for k in l0..l1 {
|
|
let Some(l) = para.lines.get(k) else { break };
|
|
if y + (l.top - first.top) + l.height > limit && kept_line {
|
|
break;
|
|
}
|
|
end = k + 1;
|
|
kept_line = true;
|
|
}
|
|
if end > l0 {
|
|
out.push(Placed::Lines { story, path, para, l0, l1: end, x, y, turn });
|
|
}
|
|
}
|
|
Placed::Fill { rect, color } if rect.y < limit => {
|
|
out.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, rect.h.min(limit - rect.y)), color });
|
|
}
|
|
Placed::Rule { x0, y0, x1, y1, border } if y0.min(y1) < limit => {
|
|
out.push(Placed::Rule { x0, y0: y0.min(limit), x1, y1: y1.min(limit), border });
|
|
}
|
|
Placed::Image { rect, .. }
|
|
| Placed::Shape { rect, .. }
|
|
| Placed::Graphic { rect, .. }
|
|
| Placed::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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
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: (part id, items, height).
|
|
notes: Vec<(u32, Vec<Placed>, f32)>,
|
|
/// 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<(Rect, bool)>,
|
|
/// 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,
|
|
}
|
|
|
|
/// Gap above the footnote separator and its length.
|
|
const NOTE_SEP: f32 = 12.0;
|
|
|
|
impl PageBuilder<'_> {
|
|
/// Space footnotes take at the bottom of the page.
|
|
fn notes_h(&self) -> f32 {
|
|
if self.notes.is_empty() { 0.0 } else { NOTE_SEP + self.notes.iter().map(|n| n.2).sum::<f32>() }
|
|
}
|
|
/// Place the collected footnotes at the bottom of the current 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;
|
|
let mut y = self.orig_bottom - total + NOTE_SEP;
|
|
let notes = std::mem::take(&mut self.notes);
|
|
if let Some(pg) = self.pages.last_mut() {
|
|
pg.items.push(Placed::Rule {
|
|
x0: x,
|
|
y0: y - NOTE_SEP / 2.0,
|
|
x1: x + 144.0,
|
|
y1: y - NOTE_SEP / 2.0,
|
|
border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 },
|
|
});
|
|
for (_, items, h) in notes {
|
|
for mut it in items {
|
|
it.translate(x, y);
|
|
pg.items.push(it);
|
|
}
|
|
y += h;
|
|
}
|
|
}
|
|
}
|
|
/// 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.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 += 1;
|
|
let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) };
|
|
let body = Rect::new(s.margin_left + s.gutter, body_top, s.text_width(), (s.page_h - s.margin_bottom - body_top).max(36.0));
|
|
let mut decor = Vec::new();
|
|
if !self.web {
|
|
// Page borders, measured from the page edge (Word's default), so they surround the
|
|
// header, footer and line numbers.
|
|
if let Some(b) = &s.page_borders {
|
|
let sp = |e: &Option<Border>| e.map(|x| x.space).unwrap_or(24.0);
|
|
let (x0, x1, y0, y1) = (sp(&b.left), s.page_w - sp(&b.right), sp(&b.top), s.page_h - sp(&b.bottom));
|
|
for (e, a, bb) in
|
|
[(b.top, (x0, y0), (x1, y0)), (b.bottom, (x0, y1), (x1, y1)), (b.left, (x0, y0), (x0, y1)), (b.right, (x1, y0), (x1, y1))]
|
|
{
|
|
if let Some(e) = e.filter(Border::is_visible) {
|
|
decor.push(Placed::Rule { x0: a.0, y0: a.1, x1: bb.0, y1: bb.1, border: e });
|
|
}
|
|
}
|
|
}
|
|
// Lines between columns.
|
|
if s.columns.separator && s.columns.count > 1 {
|
|
let cols = s.column_boxes();
|
|
for w in cols.windows(2) {
|
|
if let [(ax, aw), (bx, _)] = w {
|
|
let x = s.margin_left + s.gutter + (ax + aw + bx) / 2.0;
|
|
decor.push(Placed::Rule { x0: x, y0: body_top, x1: x, y1: s.page_h - s.margin_bottom, border: Border::single(0.5) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.page_items_start = decor.len();
|
|
self.pages.push(Page { w, h, section: self.sect_idx, number: self.number, body, first_block, items: decor, ..Default::default() });
|
|
self.col = 0;
|
|
self.cols = s.column_boxes();
|
|
self.top = body_top;
|
|
self.bottom = if self.web { f32::MAX / 8.0 } else { s.page_h - s.margin_bottom };
|
|
self.orig_bottom = self.bottom;
|
|
self.y = self.top;
|
|
}
|
|
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.col += 1;
|
|
self.y = self.top;
|
|
} else {
|
|
self.new_page(block, body_top);
|
|
}
|
|
}
|
|
fn page(&mut self) -> Option<&mut Page> {
|
|
self.pages.last_mut()
|
|
}
|
|
}
|
|
|
|
/// Lay out the whole document.
|
|
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
|
|
let t0 = now_ms();
|
|
let env = env_hash(doc, opts);
|
|
if env != cache.env {
|
|
cache.paras.clear();
|
|
cache.env = env;
|
|
}
|
|
cache.used.clear();
|
|
let notes = note_numbers(doc, opts.hide_deleted);
|
|
let notes_hash = hash_of(&{
|
|
let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, *b)).collect();
|
|
v.sort();
|
|
v
|
|
});
|
|
let mut ctx = Ctx {
|
|
doc,
|
|
cache,
|
|
opts,
|
|
counters: Counters::default(),
|
|
fields: FieldCtx {
|
|
page: 1,
|
|
pages: 1,
|
|
section_pages: 1,
|
|
section: 1,
|
|
notes: Arc::new(notes),
|
|
title: doc.core.title.as_str().into(),
|
|
author: doc.core.creator.as_str().into(),
|
|
..Default::default()
|
|
},
|
|
notes_hash,
|
|
numbers: HashMap::new(),
|
|
eq_count: 0,
|
|
boxes: wordcraft_doc::BoxBudget::default(),
|
|
};
|
|
let sections = doc.sections();
|
|
let web = opts.view != ViewMode::Print;
|
|
let default_sect = SectionProps::default();
|
|
let mut web_sect;
|
|
let first_sect = match sections.first() {
|
|
Some((_, s)) => *s,
|
|
None => &default_sect,
|
|
};
|
|
let sect_ref = if web {
|
|
web_sect = first_sect.clone();
|
|
web_sect.page_w = opts.web_width.max(144.0);
|
|
web_sect.margin_left = 18.0;
|
|
web_sect.margin_right = 18.0;
|
|
web_sect.margin_top = 18.0;
|
|
web_sect.gutter = 0.0;
|
|
web_sect.columns = Default::default();
|
|
&web_sect
|
|
} else {
|
|
first_sect
|
|
};
|
|
let mut pb = PageBuilder {
|
|
pages: Vec::new(),
|
|
sect: sect_ref,
|
|
sect_idx: 0,
|
|
col: 0,
|
|
cols: Vec::new(),
|
|
y: 0.0,
|
|
top: 0.0,
|
|
bottom: 0.0,
|
|
number: 0,
|
|
web,
|
|
notes: Vec::new(),
|
|
orig_bottom: 0.0,
|
|
prev: None,
|
|
excl: Vec::new(),
|
|
float_tables: Vec::new(),
|
|
line_no: 0,
|
|
page_items_start: 0,
|
|
};
|
|
let mut block = 0usize;
|
|
for (si, (end, sect)) in sections.iter().enumerate() {
|
|
let sect: &SectionProps = if web { sect_ref } else { sect };
|
|
pb.sect = sect;
|
|
pb.sect_idx = si;
|
|
let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, sect.headers.default) };
|
|
let first_top = if web || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, sect.headers.first) };
|
|
let restart = sect.page_num_start;
|
|
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
|
|
pb.line_no = 0;
|
|
}
|
|
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
|
if web && si > 0 {
|
|
// one long page
|
|
} else if pb.pages.is_empty() || start != SectionStart::Continuous {
|
|
if let Some(n) = restart {
|
|
pb.number = n.saturating_sub(1);
|
|
}
|
|
pb.new_page(block, first_top);
|
|
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
|
|
let want_even = start == SectionStart::EvenPage;
|
|
if pb.number.is_multiple_of(2) != want_even {
|
|
pb.new_page(block, body_top);
|
|
}
|
|
}
|
|
} else {
|
|
// Continuous: new column layout below the current text.
|
|
pb.cols = sect.column_boxes();
|
|
pb.col = 0;
|
|
pb.top = pb.y;
|
|
}
|
|
let last = (*end).min(doc.body.len().saturating_sub(1));
|
|
while block <= last {
|
|
let Some(b) = doc.body.get(block) else { break };
|
|
match &**b {
|
|
Block::Para(p) => place_para(&mut ctx, &mut pb, p, block, body_top),
|
|
Block::Table(t) => place_table(&mut ctx, &mut pb, t, block, body_top),
|
|
}
|
|
block += 1;
|
|
}
|
|
}
|
|
if pb.pages.is_empty() {
|
|
pb.new_page(0, sect_ref.margin_top);
|
|
}
|
|
// Endnotes after the last paragraph.
|
|
let endnotes: Vec<u32> = {
|
|
let mut ids: Vec<(u32, u32)> = ctx
|
|
.fields
|
|
.notes
|
|
.iter()
|
|
.filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote))
|
|
.map(|(a, b)| (*b, *a))
|
|
.collect();
|
|
ids.sort();
|
|
ids.into_iter().map(|(_, id)| id).collect()
|
|
};
|
|
if !endnotes.is_empty() && !web {
|
|
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 + 144.0,
|
|
y1: ry,
|
|
border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 },
|
|
});
|
|
}
|
|
pb.y += 8.0;
|
|
for id in endnotes {
|
|
let Some(part) = doc.parts.get(&id) else { continue };
|
|
let blocks = part.blocks.clone();
|
|
let BoxLayout { items, height: h, .. } = layout_box(&mut ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0, None);
|
|
if pb.y + h > pb.bottom && !pb.at_top() {
|
|
pb.advance(block, pb.top);
|
|
}
|
|
let (x, y) = (pb.col_x(), pb.y);
|
|
if let Some(pg) = pb.page() {
|
|
for mut it in items {
|
|
it.translate(x, y);
|
|
pg.items.push(it);
|
|
}
|
|
}
|
|
pb.y += h;
|
|
}
|
|
}
|
|
pb.apply_valign();
|
|
pb.flush_notes();
|
|
let mut pages = pb.pages;
|
|
if web && let Some(p) = pages.first_mut() {
|
|
let bottom = p
|
|
.items
|
|
.iter()
|
|
.filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None })
|
|
.fold(0.0f32, f32::max);
|
|
p.h = bottom + 36.0;
|
|
}
|
|
if !web {
|
|
headers_footers(&mut ctx, &mut pages, §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));
|
|
}
|
|
}
|
|
}
|
|
DocLayout { pages, index, ms: now_ms() - t0, text_boxes: ctx.boxes.total() }
|
|
}
|
|
|
|
fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
|
|
let f = para.lines.get(l0)?;
|
|
let l = para.lines.get(l1.checked_sub(1)?)?;
|
|
Some(y + l.top + l.height - f.top)
|
|
}
|
|
|
|
/// Where body text starts: the top margin, or below the header if it's taller.
|
|
fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32 {
|
|
let Some(id) = header else { return sect.margin_top };
|
|
let Some(part) = ctx.doc.parts.get(&id) else { return sect.margin_top };
|
|
let blocks = part.blocks.clone();
|
|
// Placed as `headers_footers` draws it, so page-relative floats wrap the same way.
|
|
let frame = PageFrame { sect, origin: (sect.margin_left + sect.gutter, sect.header) };
|
|
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height;
|
|
// Word starts the body right below a header that reaches past the top margin, no gap.
|
|
sect.margin_top.max(sect.header + h)
|
|
}
|
|
|
|
/// Where a box of laid-out items sits on its page, for floating objects positioned relative to
|
|
/// the page or its margins.
|
|
#[derive(Clone, Copy)]
|
|
struct PageFrame<'a> {
|
|
sect: &'a SectionProps,
|
|
/// Page position of the box's (0, 0); (0, 0) for body text.
|
|
origin: (f32, f32),
|
|
}
|
|
|
|
/// A floating (not inline) picture, chart or shape: its size and placement.
|
|
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
|
|
o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline)
|
|
}
|
|
|
|
/// Where a floating object goes, in the coordinates of the box `frame` places on the page.
|
|
/// `col` is the column (or cell) left edge and width, `y0` the anchor paragraph's top. Without a
|
|
/// frame (table cells, text boxes, notes) positions relative to the page or margins fall back
|
|
/// to the box itself.
|
|
fn float_rect(frame: Option<PageFrame>, col: (f32, f32), y0: f32, w: f32, h: f32, float: &Float) -> Rect {
|
|
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
|
// Each axis: the reference area's start and extent, in page coordinates.
|
|
let page_x = |s: &SectionProps| {
|
|
let left = s.margin_left + s.gutter;
|
|
match float.h_rel {
|
|
Anchor::Page => Some((0.0, s.page_w)),
|
|
Anchor::Margin => Some((left, s.text_width())),
|
|
Anchor::LeftMargin | Anchor::InsideMargin => Some((0.0, left)),
|
|
Anchor::RightMargin | Anchor::OutsideMargin => Some((s.page_w - s.margin_right, s.margin_right)),
|
|
_ => None,
|
|
}
|
|
};
|
|
let page_y = |s: &SectionProps| match float.v_rel {
|
|
Anchor::Page => Some((0.0, s.page_h)),
|
|
Anchor::Margin => Some((s.margin_top, s.page_h - s.margin_top - s.margin_bottom)),
|
|
Anchor::TopMargin | Anchor::InsideMargin => Some((0.0, s.margin_top)),
|
|
Anchor::BottomMargin | Anchor::OutsideMargin => Some((s.page_h - s.margin_bottom, s.margin_bottom)),
|
|
_ => None,
|
|
};
|
|
let hx = frame.and_then(|f| page_x(f.sect).map(|(a, e)| (a - f.origin.0, e))).unwrap_or(col);
|
|
let vy = frame.and_then(|f| page_y(f.sect).map(|(a, e)| (a - f.origin.1, e))).unwrap_or((y0, 0.0));
|
|
Rect::new(align_in(hx, float.x, w, float.h_align), align_in(vy, float.y, h, float.v_align), w, h)
|
|
}
|
|
|
|
/// Where an object of `size` starts on one axis of `area` (start, extent): at `offset` from the
|
|
/// area's start, or aligned in it when `align` is set.
|
|
fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option<FloatAlign>) -> f32 {
|
|
match align {
|
|
None => start + if offset.is_finite() { offset.clamp(-31_680.0, 31_680.0) } else { 0.0 },
|
|
Some(FloatAlign::Start | FloatAlign::Inside) => start,
|
|
Some(FloatAlign::Center) => start + (extent - size) / 2.0,
|
|
Some(FloatAlign::End | FloatAlign::Outside) => start + extent - size,
|
|
}
|
|
}
|
|
|
|
/// The area text keeps clear of around a floating object at `r` (its rectangle grown by the
|
|
/// distances from text), 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) -> Option<(Rect, bool)> {
|
|
if matches!(float.wrap, Wrap::Inline | Wrap::BehindText | Wrap::InFrontOfText) {
|
|
return None;
|
|
}
|
|
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));
|
|
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom))
|
|
}
|
|
|
|
/// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`.
|
|
fn rel_exclusions(excl: &[(Rect, bool)], x0: f32, y0: f32) -> Vec<para::Exclusion> {
|
|
excl.iter()
|
|
.map(|(r, tb)| para::Exclusion { top: r.y - y0, bottom: r.bottom() - y0, left: r.x - x0, right: r.right() - x0, top_bottom: *tb })
|
|
.filter(|e| e.bottom > 0.0)
|
|
.collect()
|
|
}
|
|
|
|
/// The items drawing floating object `o` at `rect` (a group: its members).
|
|
fn float_items(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Vec<Placed> {
|
|
match o {
|
|
InlineObject::Image { media, crop, .. } => {
|
|
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off }]
|
|
}
|
|
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
|
|
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects }]
|
|
}
|
|
InlineObject::Graphic { graphic, .. } => vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }],
|
|
InlineObject::Group { .. } => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, story, path, off)).collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// A group's members, each with its rectangle when the group is at `rect`.
|
|
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
|
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
|
|
}
|
|
|
|
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
|
|
/// the current column, add their wrap areas to the page (replacing any from an earlier attempt,
|
|
/// from `excl_mark` on) and lay the paragraph out around them, with its list `label`.
|
|
fn anchor_floats(
|
|
ctx: &mut Ctx,
|
|
pb: &mut PageBuilder,
|
|
p: &Paragraph,
|
|
y0: f32,
|
|
excl_mark: usize,
|
|
label: &Option<(String, Level)>,
|
|
) -> (HashMap<usize, Rect>, Arc<ParaLayout>) {
|
|
pb.excl.truncate(excl_mark);
|
|
ctx.fields.page = pb.number;
|
|
let (col_x, width) = (pb.col_x(), pb.col_w());
|
|
let mut rects = HashMap::new();
|
|
if !pb.web {
|
|
let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0) };
|
|
// An object left out of the layout (hidden, or deleted in the final text) is not placed, so
|
|
// it takes no room either.
|
|
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.opts.hide_deleted);
|
|
for (oi, o) in p.objects.iter().enumerate() {
|
|
let Some((w, h, float)) = floating(o) else { continue };
|
|
if left_out(oi) {
|
|
continue;
|
|
}
|
|
let r = float_rect(Some(frame), (col_x, width), y0, w, h, float);
|
|
rects.insert(oi, r);
|
|
if let Some(area) = wrap_area(r, float) {
|
|
pb.excl.push(area);
|
|
}
|
|
}
|
|
}
|
|
let rel = rel_exclusions(&pb.excl, col_x, y0);
|
|
(rects, ctx.para_labelled(p, width, None, &rel, label.clone()))
|
|
}
|
|
|
|
/// Where a paragraph's floating objects are positioned from, when they aren't placed already:
|
|
/// the box on its page, the column (left, width) and the paragraph's top. Also where offsets
|
|
/// relative to the column and paragraph start, for dragging.
|
|
#[derive(Clone, Copy)]
|
|
struct ObjFrame<'a> {
|
|
page: Option<PageFrame<'a>>,
|
|
col: (f32, f32),
|
|
para_y: f32,
|
|
/// A table's character formatting under the paragraph's (see `left_out_objects`).
|
|
table_chr: Option<&'a CharProps>,
|
|
}
|
|
|
|
/// The pictures, shapes and text boxes in lines `l0..l1` of paragraph `p` (story `story`, at
|
|
/// `path`), whose lines start at (x, y): floating ones drawn, every one's area (hit testing,
|
|
/// selection) and text boxes' text. Floating rects come from `floats` (by object index) or are
|
|
/// worked out from `at`. Returns what goes behind the text, and what goes in front.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn place_objects(
|
|
ctx: &mut Ctx,
|
|
story: StoryRef,
|
|
path: &[u32],
|
|
p: &Paragraph,
|
|
pl: &ParaLayout,
|
|
(l0, l1): (usize, usize),
|
|
(x, y): (f32, f32),
|
|
at: &ObjFrame,
|
|
floats: &HashMap<usize, Rect>,
|
|
depth: usize,
|
|
) -> (Vec<Placed>, Vec<Placed>) {
|
|
let (mut behind, mut front) = (Vec::new(), Vec::new());
|
|
let Some(fl) = pl.lines.get(l0) else { return (behind, front) };
|
|
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
|
|
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.opts.hide_deleted);
|
|
for li in l0..l1 {
|
|
let Some(line) = pl.lines.get(li) else { continue };
|
|
for k in line.c0..line.c1 {
|
|
let Some(c) = pl.clusters.get(k) else { continue };
|
|
let para::ClKind::Object(oi) = c.kind else { continue };
|
|
if left_out(oi) {
|
|
continue;
|
|
}
|
|
let Some(obj) = p.objects.get(oi) else { continue };
|
|
let Some((w, h, float)) = obj.frame() else { continue };
|
|
let floating = float.wrap != Wrap::Inline;
|
|
let rect = if floating {
|
|
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float))
|
|
} else {
|
|
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
|
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
|
|
};
|
|
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
|
// Floating ones are drawn here; inline ones with their line.
|
|
if floating {
|
|
layer.extend(float_items(obj, rect, 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,
|
|
};
|
|
front.push(Placed::Object {
|
|
rect,
|
|
story,
|
|
path: Path(path.to_vec()),
|
|
off: c.start,
|
|
text_box,
|
|
wrap: float.wrap,
|
|
origin: Point::new(at.col.0, at.para_y),
|
|
});
|
|
// Its text (a group: its text boxes'), unless the box budget says no (a box inside
|
|
// itself, too deep, too many).
|
|
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, r))
|
|
})
|
|
.collect(),
|
|
_ => text_box.map(|id| (id, rect)).into_iter().collect(),
|
|
};
|
|
for (id, rect) in boxes {
|
|
if !ctx.boxes.enter(id) {
|
|
continue;
|
|
}
|
|
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
|
let inner = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None).items;
|
|
ctx.boxes.leave();
|
|
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
|
// Text that doesn't fit inside the margins is hidden, as in Word.
|
|
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
|
|
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
|
|
layer.push(it);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(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 };
|
|
if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.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<(u32, Vec<Placed>, f32)> = 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.0 == *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((*id, items, h));
|
|
}
|
|
}
|
|
}
|
|
let extra: f32 = line_notes.iter().map(|n| n.2).sum::<f32>()
|
|
+ if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { NOTE_SEP } else { 0.0 };
|
|
let limit = pb.bottom
|
|
- new_notes.iter().map(|n| n.2).sum::<f32>()
|
|
- if pb.notes.is_empty() && !new_notes.is_empty() { NOTE_SEP } else { 0.0 }
|
|
- extra;
|
|
let bottom = pb.y + lead + (l.top + l.height - first.top);
|
|
if bottom > limit + 0.01 && l1 > l0 {
|
|
break;
|
|
}
|
|
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() {
|
|
break;
|
|
}
|
|
new_notes.extend(line_notes);
|
|
l1 += 1;
|
|
if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web {
|
|
forced_break = Some(l.end);
|
|
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(|(id, ..)| notes.iter().any(|(ci, nid)| nid == id && *ci < placed_end));
|
|
let before = pb.notes_h();
|
|
pb.notes.extend(new_notes);
|
|
pb.bottom -= pb.notes_h() - before;
|
|
let x = pb.col_x();
|
|
let y = pb.y + lead;
|
|
let mut items = Vec::new();
|
|
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width);
|
|
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
|
|
let at = ObjFrame {
|
|
page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }),
|
|
col: (x, width),
|
|
para_y: if pb.web { y } else { anchor_y },
|
|
table_chr: None,
|
|
};
|
|
let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], p, &pl, (l0, l1), (x, y), &at, &float_rects, 0);
|
|
items.extend(front);
|
|
// Line numbers in the left margin.
|
|
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && p.props.suppress_line_numbers != Some(true))
|
|
&& let Some(first) = pl.lines.get(l0)
|
|
{
|
|
for li in l0..l1 {
|
|
let Some(line) = pl.lines.get(li).filter(|l| !l.beside) else { continue };
|
|
pb.line_no += 1;
|
|
let n = pb.line_no + 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: StoryRef::Part(u32::MAX),
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
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.get(l0), pl.lines.get(l1 - 1)) {
|
|
pb.y = y + (l.top + l.height - f.top);
|
|
}
|
|
l0 = l1;
|
|
match forced_break {
|
|
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
|
Some(LineEnd::ColumnBreak) => pb.advance(block, body_top),
|
|
_ if l0 < n => pb.advance(block, body_top),
|
|
_ => {}
|
|
}
|
|
}
|
|
pb.y += pl.rp.space_after;
|
|
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after));
|
|
}
|
|
|
|
/// Height of the first line of the block after `block` (for keep-with-next).
|
|
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
|
match ctx.doc.body.get(block + 1).map(|b| &**b) {
|
|
Some(Block::Para(p)) => {
|
|
// Don't advance list counters for a lookahead: lay out without the label.
|
|
let rp = ctx.doc.styles.resolve_para(&p.props);
|
|
let env = para::ParaEnv {
|
|
doc: ctx.doc,
|
|
width,
|
|
label: None,
|
|
fields: &ctx.fields,
|
|
show_hidden: ctx.opts.show_hidden,
|
|
hide_deleted: ctx.opts.hide_deleted,
|
|
table: None,
|
|
proofing: false,
|
|
exclusions: &[],
|
|
eq_number: ctx.eq_count,
|
|
};
|
|
let _ = rp;
|
|
let pl = para::layout_para(p, &env);
|
|
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
|
}
|
|
Some(Block::Table(_)) => 24.0,
|
|
None => 0.0,
|
|
}
|
|
}
|
|
|
|
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
|
pb.prev = None;
|
|
let width = pb.col_w();
|
|
let mut tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1);
|
|
if let Some(f) = t.props.float.filter(|_| !pb.web) {
|
|
// Before Word 2013 layout (compatibility mode 15) an offset places the first cell's text,
|
|
// so the edge sits a cell margin further out.
|
|
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
|
let h: f32 = tl.rows.iter().map(|r| r.height).sum();
|
|
if h <= pb.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;
|
|
};
|
|
for (ri, row) in tl.rows.iter().enumerate() {
|
|
let splittable = !header_rows.contains(&ri) && !t.rows.get(ri).is_some_and(|r| r.props.cant_split);
|
|
let mut rest: Option<table::RowLayout> = None;
|
|
// On a new page holding only the repeated header rows.
|
|
let mut fresh = false;
|
|
// Each pass places the part of the row that fits, then breaks the page.
|
|
for _ in 0..1000 {
|
|
let cur = rest.as_ref().unwrap_or(row);
|
|
if pb.y + cur.height <= pb.bottom + 0.01 {
|
|
break;
|
|
}
|
|
let split = if splittable { table::split_row(cur, pb.bottom - pb.y) } else { None };
|
|
match split {
|
|
Some((a, b)) => {
|
|
place(pb, &a);
|
|
rest = Some(b);
|
|
}
|
|
// Like Word, a row that does not fit on an empty page, or under the header rows
|
|
// repeated there, is placed anyway and runs into the bottom margin.
|
|
None if pb.at_top() || fresh => break,
|
|
None => {}
|
|
}
|
|
pb.advance(block, body_top);
|
|
// Repeat header rows.
|
|
if !header_rows.contains(&ri) {
|
|
for hr in &header_rows {
|
|
if let Some(h) = tl.rows.get(*hr) {
|
|
place(pb, h);
|
|
}
|
|
}
|
|
}
|
|
fresh = true;
|
|
}
|
|
place(pb, rest.as_ref().unwrap_or(row));
|
|
}
|
|
}
|
|
|
|
/// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text
|
|
/// flow; the text after it wraps around it.
|
|
fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) {
|
|
let h: f32 = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
|
let w = tl.width.clamp(1.0, 100_000.0);
|
|
let [dl, dt, dr, db] = f.dist_from_text();
|
|
for attempt in 0..2 {
|
|
let s = pb.sect;
|
|
let h_area = float_table_h_area(pb, f);
|
|
// The text a table anchored to it stands in: where the next paragraph starts.
|
|
let v_area = match f.v_rel {
|
|
Anchor::Page => (0.0, s.page_h),
|
|
Anchor::Margin => (s.margin_top, s.text_height()),
|
|
_ => (pb.y, 0.0),
|
|
};
|
|
let x = float_table_x(pb, w, f, legacy);
|
|
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &pb.float_tables);
|
|
if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph {
|
|
pb.advance(block, body_top);
|
|
continue;
|
|
}
|
|
if let Some(pg) = pb.page() {
|
|
let mut y = r.y;
|
|
for row in &tl.rows {
|
|
for it in &row.items {
|
|
let mut it = it.clone();
|
|
it.translate(r.x, y);
|
|
pg.items.push(it);
|
|
}
|
|
y += row.height;
|
|
}
|
|
}
|
|
let area = r.inset(-dl, -dt, -dr, -db);
|
|
pb.excl.push((area, false));
|
|
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: &[(Rect, bool)], mut y: f32, x0: f32, x1: f32) -> f32 {
|
|
for _ in 0..64 {
|
|
let in_the_way = excl.iter().filter(|(r, _)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0);
|
|
let Some(bottom) = in_the_way.map(|(r, _)| r.bottom()).reduce(f32::max) else { break };
|
|
y = bottom;
|
|
}
|
|
y
|
|
}
|
|
|
|
fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) {
|
|
let total = pages.len() as u32;
|
|
// Pages per section.
|
|
let mut per_section: HashMap<usize, u32> = HashMap::new();
|
|
for p in pages.iter() {
|
|
*per_section.entry(p.section).or_default() += 1;
|
|
}
|
|
let even_odd = ctx.doc.settings.even_odd_headers;
|
|
let mut first_of_section: HashSet<usize> = HashSet::new();
|
|
let mut seen = HashSet::new();
|
|
for (i, p) in pages.iter().enumerate() {
|
|
if seen.insert(p.section) {
|
|
first_of_section.insert(i);
|
|
}
|
|
}
|
|
for (i, page) in pages.iter_mut().enumerate() {
|
|
let Some((_, sect)) = sections.get(page.section) else { continue };
|
|
// Inherit header/footer references from earlier sections (Word's "link to previous").
|
|
let pick = |get: &dyn Fn(&SectionProps) -> wordcraft_doc::section::HeaderSet| -> Option<u32> {
|
|
let mut set = get(sect);
|
|
for k in (0..page.section).rev() {
|
|
if set.default.is_some() && set.first.is_some() && set.even.is_some() {
|
|
break;
|
|
}
|
|
if let Some((_, s)) = sections.get(k) {
|
|
let o = get(s);
|
|
set.default = set.default.or(o.default);
|
|
set.first = set.first.or(o.first);
|
|
set.even = set.even.or(o.even);
|
|
}
|
|
}
|
|
if sect.title_page && first_of_section.contains(&i) {
|
|
return set.first;
|
|
}
|
|
if even_odd && page.number % 2 == 0 {
|
|
return set.even.or(set.default);
|
|
}
|
|
set.default
|
|
};
|
|
let hid = pick(&|s: &SectionProps| s.headers);
|
|
let fid = pick(&|s: &SectionProps| s.footers);
|
|
ctx.fields.page = page.number;
|
|
ctx.fields.page_format = sect.page_num_format;
|
|
ctx.fields.pages = total;
|
|
ctx.fields.section_pages = per_section.get(&page.section).copied().unwrap_or(1);
|
|
ctx.fields.section = page.section as u32 + 1;
|
|
let w = sect.text_width();
|
|
let x = sect.margin_left + sect.gutter;
|
|
if let Some(id) = hid
|
|
&& let Some(part) = ctx.doc.parts.get(&id)
|
|
{
|
|
let blocks = part.blocks.clone();
|
|
let frame = PageFrame { sect, origin: (x, sect.header) };
|
|
let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items;
|
|
for it in &mut items {
|
|
it.translate(x, sect.header);
|
|
}
|
|
page.header = items;
|
|
page.header_story = Some(id);
|
|
}
|
|
if let Some(id) = fid
|
|
&& let Some(part) = ctx.doc.parts.get(&id)
|
|
{
|
|
let blocks = part.blocks.clone();
|
|
// The footer's height fixes where it sits, which page-relative floats need.
|
|
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, None).height;
|
|
let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h) };
|
|
let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0, Some(frame)).items;
|
|
for it in &mut items {
|
|
it.translate(x, sect.page_h - sect.footer - h);
|
|
}
|
|
page.footer = items;
|
|
page.footer_story = Some(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Is an object floating (not laid out inline)?
|
|
pub fn is_floating(o: &InlineObject) -> bool {
|
|
o.is_floating()
|
|
}
|
|
|
|
/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web).
|
|
pub fn now_ms() -> f64 {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
|
T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0
|
|
}
|
|
// `Instant::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let ms = js_sys::Date::now();
|
|
if ms.is_finite() { ms } else { 0.0 }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
#[cfg(test)]
|
|
mod tests_bidi;
|