Files
wordcraft/crates/layout/src/lib.rs
T

358 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordCraft layout: turns a [`Document`] into pages.
//!
//! - [`para`]: one paragraph → lines (shaping, first-fit line breaking, tabs, list labels,
//! alignment). Results are memoised per paragraph revision in a [`LayoutCache`].
//! - [`layout`]: sections → pages and columns; tables; keep/widow rules; page breaks; headers
//! and footers; page-number fields.
//! - [`display`]: a page → draw items (glyph runs, rules, fills, images) for the renderers.
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod balloons;
mod contour;
pub mod display;
pub mod fields;
pub mod hit;
pub mod kinsoku;
pub mod math;
pub mod outline;
pub mod para;
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, &note_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;