Files
wordcraft/crates/layout/src/lib.rs
T
Aleksander HelgakerandClaude Opus 5.5 ba58e70cc8 Flag document fonts that aren't installed (#4770)
* Gap 4: flag document fonts that aren't installed

The font box (ribbon, mini toolbar, both Font dialog boxes) shows a
warning mark when the current font isn't installed; its hover note
names the font drawn instead. The status bar says "N fonts not
installed" and lists each with the faces drawn instead, plus Add Font….
Agents get `font.missing` and `format.state.fontShownAs`.

Layout records, where it picks a face per text segment, the fonts the
shown text asks for that aren't installed and the faces drawn instead
(ParaLayout::substituted, DocLayout::substituted_fonts), so hidden
text, unused styles and orphaned stories never count; list labels,
field results and East Asian and complex-script text do, and so does a
missing roman font drawn in Cambria. Metric-compatible substitutes and
symbol fonts drawn as Unicode aren't flagged. Bounded for hostile
files: 64 pairs a paragraph, 1024 a document, names kept to 256
characters, 20 rows in the popup.

Ported from alehel/wordcraft#1 onto current main; English strings only
(translations belong in storytold/craft-i18n).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: missing-font review fixes

- Judge a padded font name by the face layout drew for it, and skip blank names.
- Name the face CJK text is drawn in for a missing East Asian font, not Cambria.
- Keep the missing-fonts list per window, so several windows don't rebuild it every frame.
- Expose the font box's warning to screen readers as its description.
- Count missing fonts with craft-i18n plurals (`i18n::tn`).
- One table of metric twins (`fonts::word::METRIC_TWINS`): a missing font is drawn in an
  installed twin first, and the warning reads the same table.
- Find the status-bar item by part of its label in the click test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: warn for a font picked at the caret; drop fonts of hidden list items

- At a collapsed caret the font box warns for a missing font typing will use, also at the end
  of text in another font (it waited for the first letter).
- A list item that shows nothing (#3982) reports no missing font: its number was shaped, and
  its font recorded, before layout dropped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: say where Add Font… is, and that it needs the font file

The font box's note said "Add Font… installs it": Add Font… opens a file picker at the end of
the list, so it only helps with a font file at hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: docs say the popup lists 20, and when the font box warns ahead

CodeRabbit review: the status-bar popup lists the first 20 missing fonts, then a count; the font
box also warns at a collapsed caret for a font no shown text uses yet; `fontShownAs` is the
font's substitute, fallback faces aside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: the Font dialog warns for a missing font over a selection

CodeRabbit review: the dialog's boxes show the fonts OK applies, which no shown text may use
yet (an Asian text font over Latin text, a font typed into the dialog), so they name the
substitute whatever is selected. The ribbon's box over a selection still reads the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: the Asian font box names the face CJK text gets

CodeRabbit review: the status bar's list is by font name, so for a font Latin text also uses
it named the Latin text's substitute. The Asian text box now always looks up the face layout
draws Chinese, Japanese and Korean text in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: gaps.md status on the current line, missing-font scope and twin wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: gaps.md says which missing fonts are flagged and what font.missing lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: font.missing lists what the current view lays out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:48 +01:00

363 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 substituted;
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};
pub use substituted::MAX_SUBSTITUTED;
/// 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;