mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
358 lines
14 KiB
Rust
358 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::{BoxContext, 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;
|
||
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;
|