mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
* #3867: review — header repeats, picture outlines and linked-box widths bounded the same way every layout - Header repeats: a Clip (floating Drawing Canvas) counts its members, depth-bounded, so a 1000-member canvas no longer counts as one item (CodeRabbit thread 4373103686); the budget is per layout (Ctx), not per table. - Tight-wrap outlines: each cache entry stores its picture's pixel cost; a cached hit is charged to the layout's decode budget like a decode, so cold and warm layouts (and the CLI) wrap alike. - Linked boxes: widths are budgeted by laid-out work (MAX_FLOW_WORK), not 16 widths, so ordinary stories keep exact widths for every box. - header_rows.contains -> index compare; layout-parity doc revision. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 * #3867: review — a picture that fails to decode still costs its pixels when cached; header repeats restored on retry - contour: Alphas records the pixels each picture charged (costs map), also when the body then fails to decode, so the cache entry's cost is right and warm layouts charge it like cold ones. - Snap saves and restores ctx.header_repeats so balance_pass retries don't keep their charge. - Tests: truncated large PNG cold vs warm; canvas header row end to end through place_table; linked-chain bound <= 8; vertical box's layout differs from every other box's. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 * #3867: CodeRabbit — header and linked-box budgets count every clip descendant placed_cost counts iteratively (explicit stack, stops past the limit) and is shared by flow_work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 * #3867: docs — progress log, gap 20 note for layout resource caps and linear-time passes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 * #3867: review — roadmap gives each cap its unit: header items, wrap-cache pixels, linked text box lines and items Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #3867: CodeRabbit — the vertical-box check runs past the work cap too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #3867: review — a linked chain's work counts the story it lays out, not only the lines it shows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #3867: review — header-row cost import follows main's explicit layout imports (#4501) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #3867: review — a test pins the header repeats that column balancing gives back; docs keep main's caps row and both gap status lines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
361 lines
14 KiB
Rust
361 lines
14 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;
|
||
pub mod styleref;
|
||
mod table;
|
||
mod textbox;
|
||
mod vertical;
|
||
pub mod wordart;
|
||
mod work;
|
||
|
||
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, HeightRule, 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, cell_margins, measure_table_columns};
|
||
|
||
mod boxes;
|
||
mod cache;
|
||
mod ctx;
|
||
mod floats;
|
||
mod flow;
|
||
mod headers;
|
||
mod notes;
|
||
mod page;
|
||
mod pass;
|
||
mod placed;
|
||
mod table_flow;
|
||
|
||
#[cfg(test)]
|
||
use boxes::{BOX_INSET_X, BOX_INSET_Y};
|
||
use boxes::{BoxInput, BoxLayout, Edges, fit_box, layout_box, push_para};
|
||
pub use cache::LayoutCache;
|
||
use cache::{MAX_TOTAL_PASSES, Totals};
|
||
#[cfg(test)]
|
||
use ctx::picture_outline;
|
||
use ctx::{Ctx, Pass};
|
||
use floats::{EarlyFloats, MAX_REWRAP_PASSES, merge_early};
|
||
pub use floats::{is_floating, shape_figure_alt};
|
||
#[cfg(test)]
|
||
use floats::{left_out_objects, wrap_area};
|
||
#[cfg(test)]
|
||
use headers::MAX_HEADER_ITEMS;
|
||
#[cfg(test)]
|
||
use headers::placed_cost;
|
||
use headers::placed_cost_by;
|
||
pub use notes::DECOR_STORY;
|
||
pub(crate) use notes::NOTE_SEP_W;
|
||
use notes::{MAX_NOTE_PASSES, footnote_pages};
|
||
#[cfg(test)]
|
||
use notes::{NOTE_SEP, note_numbers};
|
||
use page::item_bottom;
|
||
use pass::layout_pass;
|
||
pub use pass::now_ms;
|
||
pub use placed::{HfSlot, Placed, turn_point, turn_rect, unturn_point};
|
||
|
||
/// 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,
|
||
/// Reading: reflow into viewport-height narrow pages, navigated horizontally.
|
||
Read,
|
||
/// Outline: like draft, the body as an outline of its headings ([`outline`]).
|
||
Outline,
|
||
}
|
||
|
||
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
|
||
///
|
||
/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and
|
||
/// the saved file all see the original digits. Only ASCII digits are substituted: Arabic-Indic
|
||
/// (U+0660–U+0669) and Extended Arabic-Indic digits (U+06F0–U+06F9, Persian/Urdu ۰۱۲۳…) always
|
||
/// show as typed, so fonts that ligate them (e.g. the end-of-ayah mark U+06DD) keep working.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum NumeralMode {
|
||
/// Western digits everywhere (`0123456789`); the default, today's behaviour.
|
||
#[default]
|
||
Arabic,
|
||
/// Eastern Arabic-Indic digits everywhere (`٠١٢٣٤٥٦٧٨٩`).
|
||
Hindi,
|
||
/// Hindi digits in and around right-to-left text, Western digits elsewhere (numbers take
|
||
/// an even bidi level, so the surrounding text decides).
|
||
Context,
|
||
/// Hindi digits when the system locale is an Arabic-script language, else Western digits.
|
||
/// Call [`NumeralMode::resolve`] before laying out.
|
||
System,
|
||
}
|
||
|
||
impl NumeralMode {
|
||
/// Short UI label (Word's option names).
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
NumeralMode::Arabic => "Arabic",
|
||
NumeralMode::Hindi => "Hindi",
|
||
NumeralMode::Context => "Context",
|
||
NumeralMode::System => "System",
|
||
}
|
||
}
|
||
|
||
/// Parse a command value (`"arabic" | "hindi" | "context" | "system"`, any case).
|
||
pub fn parse(s: &str) -> Option<NumeralMode> {
|
||
match s.to_lowercase().as_str() {
|
||
"arabic" => Some(NumeralMode::Arabic),
|
||
"hindi" => Some(NumeralMode::Hindi),
|
||
"context" => Some(NumeralMode::Context),
|
||
"system" => Some(NumeralMode::System),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Resolve `System` against the system locale (a BCP 47 or POSIX tag such as `ar-EG` or
|
||
/// `fa_IR.UTF-8`, found by the shell: layout never reads the environment). Hindi digits for
|
||
/// Arabic-script languages, else Western; unknown locale = Western. Other modes pass through.
|
||
pub fn resolve(self, system_locale: Option<&str>) -> NumeralMode {
|
||
if self != NumeralMode::System {
|
||
return self;
|
||
}
|
||
if system_locale.is_some_and(hindi_locale) { NumeralMode::Hindi } else { NumeralMode::Arabic }
|
||
}
|
||
|
||
/// The displayed form of `c` in a run shaped with direction `rtl`.
|
||
///
|
||
/// One character maps to exactly one character, so shaped text stays codepoint-aligned
|
||
/// with the document text (only byte offsets shift). A `System` mode that was not
|
||
/// resolved falls back to Western digits.
|
||
pub fn map(self, c: char, rtl: bool) -> char {
|
||
let hindi = match self {
|
||
NumeralMode::Arabic => false,
|
||
NumeralMode::Hindi => true,
|
||
NumeralMode::Context => rtl,
|
||
NumeralMode::System => false,
|
||
};
|
||
if hindi { if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } } else { c }
|
||
}
|
||
}
|
||
|
||
/// Does locale `tag` use Hindi digits (Arabic, Persian, Urdu, Pashto, Sindhi, Kurdish)?
|
||
fn hindi_locale(tag: &str) -> bool {
|
||
let lang = tag.split(['_', '-', '.', '@']).next().unwrap_or_default().to_ascii_lowercase();
|
||
matches!(lang.as_str(), "ar" | "fa" | "ur" | "ps" | "sd" | "ku" | "ckb")
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct LayoutOptions {
|
||
pub view: ViewMode,
|
||
/// Width of the window in Web/Draft views, points.
|
||
pub web_width: f32,
|
||
/// Page height in Reading Mode (document points).
|
||
pub read_height: f32,
|
||
/// Transient read-only line-spacing override; ignored by print/export views.
|
||
pub read_line_spacing: Option<f32>,
|
||
pub show_hidden: bool,
|
||
/// Final text ("No Markup"): tracked deletions take no space and draw nothing.
|
||
pub hide_deleted: bool,
|
||
/// Tracked deletions that take no space and draw nothing although markup is shown: those
|
||
/// Track Changes Options mark Hidden (deletions, or moved-away text). `hide_deleted` and
|
||
/// `deletions_in_balloons` leave every deletion out whatever this says.
|
||
pub hide_marked: HideDeleted,
|
||
/// 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,
|
||
/// How digits look on screen (display only; the text is untouched). Callers resolve
|
||
/// `System` ([`NumeralMode::resolve`]); an unresolved `System` shows Western digits.
|
||
pub numeral: NumeralMode,
|
||
/// 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,
|
||
/// The word being typed, which gets no spelling squiggle until the caret leaves it (#1552).
|
||
pub typing_word: Option<TypingWord>,
|
||
/// Document file name for FILENAME fields ([`FieldCtx::filename`]).
|
||
pub filename: Option<Arc<str>>,
|
||
/// Document full path for FILENAME \p fields ([`FieldCtx::filepath`]).
|
||
pub filepath: Option<Arc<str>>,
|
||
}
|
||
|
||
/// The word being typed ([`LayoutOptions::typing_word`]): the paragraph by its revision
|
||
/// ([`Paragraph::rev`]) and the word's byte range in it.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub struct TypingWord {
|
||
pub rev: u64,
|
||
pub start: usize,
|
||
pub end: usize,
|
||
}
|
||
|
||
/// 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 {
|
||
/// Which tracked deletions in `story` take no space and draw nothing.
|
||
pub fn hides_deleted(&self, story: StoryRef) -> HideDeleted {
|
||
if self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body) { HideDeleted::ALL } else { self.hide_marked }
|
||
}
|
||
}
|
||
|
||
/// Which tracked deletions are left out of the layout: ordinary ones, and the text a tracked
|
||
/// move took away (the source of a move, `w:moveFrom`).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub struct HideDeleted {
|
||
pub plain: bool,
|
||
pub moved_from: bool,
|
||
}
|
||
|
||
impl HideDeleted {
|
||
pub const ALL: HideDeleted = HideDeleted { plain: true, moved_from: true };
|
||
/// Whether any deletion is left out.
|
||
pub fn any(self) -> bool {
|
||
self.plain || self.moved_from
|
||
}
|
||
/// Whether text deleted by revision `del` (`None`: not deleted) is left out.
|
||
pub fn hides(self, doc: &Document, del: Option<u32>) -> bool {
|
||
del.is_some() && if doc.move_name(del).is_some() { self.moved_from } else { self.plain }
|
||
}
|
||
}
|
||
|
||
/// 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>,
|
||
/// Which of its section's headers/footers this page shows (first page, even page or the
|
||
/// default), whether or not the section has one: double-clicking an empty header area
|
||
/// creates that one (#960).
|
||
pub hf_slot: HfSlot,
|
||
/// 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 top-level body tables placed on this page as floating tables (`w:tblpPr`): a click
|
||
/// outside one doesn't land in its cells ([`DocLayout::hit`]).
|
||
pub float_tables: Vec<Path>,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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 early, mut rewraps) = (EarlyFloats::new(), 0);
|
||
let mut l = loop {
|
||
match layout_pass(doc, cache, opts, ¬e_pages, &totals, passes < MAX_TOTAL_PASSES, (&early, rewraps < MAX_REWRAP_PASSES)) {
|
||
Pass::Again(t) => {
|
||
totals = t;
|
||
passes += 1;
|
||
}
|
||
Pass::Rewrap(found) => {
|
||
merge_early(&mut early, found);
|
||
rewraps += 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
|
||
}
|
||
|
||
#[cfg(test)]
|
||
thread_local! {
|
||
/// How many wrap relays (#1374) the layouts on this thread made: tests check none happen
|
||
/// without something new to wrap around.
|
||
static WRAP_RELAYS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|