Files
wordcraft/crates/layout/src/para.rs
T
74a279d5f2 Fonts: a missing roman font is drawn in Cambria, as in Word (#1613)
WordCraft chose a substitute for a font that isn't installed from its
name alone, so an unknown brand font always came out sans. Word reads the
font table instead: measured with the same missing font, it draws it in
Cambria when w:family is roman and in Calibri when it is swiss or auto.
A report whose font table calls its brand font roman got a sans footer
where Word draws serif.

The font table's roman fonts are read into the settings, and layout looks
a missing one up as Cambria (Caladea where Cambria isn't installed). The
other classes keep the existing substitutes, which already start with
Calibri's metric clone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:31:42 -04:00

2793 lines
128 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.
//! Paragraph layout: resolve runs, shape them into clusters, break lines (first-fit, as word
//! processors do), place tabs, list labels and alignment.
use std::collections::VecDeque;
use std::sync::Arc;
use wordcraft_doc::math::MathJc;
use wordcraft_doc::numbering::{Level, LevelSuffix};
use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, RubyAlign, SOFT_HYPHEN, WrapText};
use wordcraft_doc::props::{Align, CharProps, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
use wordcraft_doc::{Document, Paragraph};
use wordcraft_fonts::FaceRef;
use crate::NumeralMode;
use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text_on};
/// How a run set in one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings)
/// shows its characters. Those fonts are proprietary and usually missing: a substitute has other
/// glyphs, or none, at their codes, so each character is drawn as its Unicode equivalent
/// ([`wordcraft_doc::symbol_fonts`]) from a face that has it. The text itself is unchanged.
#[derive(Clone, Copy)]
struct SymbolRun<'a> {
family: &'a str,
/// The symbol font itself is installed (`face` is it).
installed: bool,
face: FaceRef,
}
impl<'a> SymbolRun<'a> {
fn of(family: &'a str, r: &wordcraft_fonts::word::Resolved) -> Option<Self> {
wordcraft_doc::symbol_fonts::is_symbol_font(family).then_some(SymbolRun { family, installed: !r.substituted, face: r.face })
}
/// The character drawn for `c`.
fn show(&self, c: char) -> char {
if self.installed {
if self.face.covers(c) {
return c;
}
// A plain 8-bit code in the installed font: its glyph sits at the private-use code.
if let Some(p) = (0x20..=0xFF).contains(&u32::from(c)).then(|| char::from_u32(0xF000 + u32::from(c))).flatten()
&& self.face.covers(p)
{
return p;
}
}
let Some(u) = wordcraft_doc::symbol_fonts::symbol_char(self.family, c) else { return c };
// A few list bullets have common look-alikes in far more fonts: use one when no face
// has the exact character.
let alt = match u {
'\u{26AB}' => Some('\u{25CF}'),
'\u{25FC}' => Some('\u{25A0}'),
'\u{2B9A}' => Some('\u{27A2}'),
_ => None,
};
let has = |ch: char| self.face.covers(ch) || wordcraft_fonts::FontDb::global().fallback_for(ch, self.face.id()).is_some();
match alt {
Some(a) if !has(u) && has(a) => a,
_ => u,
}
}
}
/// A face at a size with the formatting needed to draw it.
#[derive(Clone, Debug)]
pub struct StyleRun {
pub face: FaceRef,
pub size: f32,
pub synth_bold: bool,
pub synth_italic: bool,
pub rc: Arc<ResolvedChar>,
/// Line ascent/descent at this size, points.
pub ascent: f32,
pub descent: f32,
/// Baseline shift (superscript etc.), points, positive up.
pub shift: f32,
}
/// A positioned glyph inside its cluster.
#[derive(Clone, Copy, Debug)]
pub struct Glyph {
pub gid: u32,
/// Offset from the cluster's x, points.
pub dx: f32,
pub dy: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClKind {
Text,
Space,
Tab,
LineBreak,
PageBreak,
ColumnBreak,
/// Inline object with its index in `Paragraph::objects`.
Object(usize),
/// Zero-width marker (bookmark, comment anchor) or hidden text.
Marker,
}
/// The smallest unit of layout: a grapheme cluster (or object, tab, break).
#[derive(Clone, Debug)]
pub struct Cluster {
pub start: usize,
pub end: usize,
pub adv: f32,
pub kind: ClKind,
/// Index into `ParaLayout::styles`.
pub style: u32,
/// Glyph range in `ParaLayout::glyphs`.
pub g0: u32,
pub g1: u32,
/// A line may break after this cluster.
pub break_after: bool,
/// Height above the baseline for objects (images), points.
pub obj_h: f32,
/// Depth below the baseline for objects (equations), points.
pub obj_d: f32,
/// The cluster is a decimal separator (decimal tabs align on it).
pub dot: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineEnd {
/// Wrapped (or last line).
Wrap,
LineBreak,
PageBreak,
ColumnBreak,
/// End of paragraph.
Para,
}
/// A laid-out line. Coordinates are relative to the paragraph's content box: x from the
/// column's left edge, y from the top of the first line.
#[derive(Clone, Debug)]
pub struct Line {
pub top: f32,
pub height: f32,
pub baseline: f32,
/// Cluster index range.
pub c0: usize,
pub c1: usize,
/// x of each cluster in the line, plus the end x (len = c1 - c0 + 1).
pub xs: Vec<f32>,
/// For tab clusters: the leader to draw across them.
pub leaders: Vec<(usize, TabLeader)>,
pub end: LineEnd,
/// Byte range of the paragraph covered by the line.
pub start: usize,
pub stop: usize,
/// Left and right edges available to the line.
pub left: f32,
pub right: f32,
/// The line ends at a hyphenation point: (style, glyph, advance) of the hyphen drawn there.
pub hyphen: Option<(u32, u32, f32)>,
/// The text continues the previous line's row on the far side of a floating object (same top
/// and height): one row, two lines. Row counts (line numbers, keeping rows on one page) skip it.
pub beside: bool,
/// Bidirectional lines (right-to-left text, or any line of a right-to-left paragraph): each
/// cluster's visual box, in cluster order (len = c1 - c0), after the Unicode Bidirectional
/// Algorithm reordered the line. Empty for plain left-to-right lines, whose clusters sit at
/// `xs`. Read cluster geometry through [`Line::cl_left`] and friends, which handle both.
pub vis: Vec<VisCl>,
/// The paragraph reads right to left (its start edge is on the right).
pub rtl: bool,
}
/// Where a cluster of a bidirectional line is drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VisCl {
/// Left edge and width, points (paragraph coordinates, like `Line::xs`).
pub x: f32,
pub w: f32,
/// Embedding level after rule L1 of UAX #9 (odd = right to left).
pub level: u8,
}
impl VisCl {
pub fn is_rtl(&self) -> bool {
self.level % 2 == 1
}
/// The edge a caret before this cluster sits at (right edge of right-to-left text).
pub fn leading(&self) -> f32 {
if self.is_rtl() { self.x + self.w } else { self.x }
}
/// The edge a caret after this cluster sits at.
pub fn trailing(&self) -> f32 {
if self.is_rtl() { self.x } else { self.x + self.w }
}
}
/// Width of a paragraph mark's box, points: what a selection running past the paragraph's end
/// highlights, and how much room the mark takes for assistive technology.
pub const PARA_MARK_WIDTH: f32 = 5.0;
impl Line {
/// Left edge of cluster `k` (a paragraph cluster index on this line).
pub fn cl_left(&self, k: usize) -> Option<f32> {
let i = k.checked_sub(self.c0)?;
if self.vis.is_empty() { self.xs.get(i).copied() } else { self.vis.get(i).map(|v| v.x) }
}
/// Right edge of cluster `k`.
pub fn cl_right(&self, k: usize) -> Option<f32> {
let i = k.checked_sub(self.c0)?;
if self.vis.is_empty() { self.xs.get(i + 1).copied() } else { self.vis.get(i).map(|v| v.x + v.w) }
}
/// Is cluster `k` right-to-left text?
pub fn cl_rtl(&self, k: usize) -> bool {
k.checked_sub(self.c0).and_then(|i| self.vis.get(i)).is_some_and(VisCl::is_rtl)
}
/// Caret x after the line's last cluster (its logical end), hyphen included.
pub fn end_x(&self) -> f32 {
let h = self.hyphen.map_or(0.0, |h| h.2);
match self.vis.last() {
Some(v) if v.is_rtl() => v.x - h,
Some(v) => v.x + v.w + h,
None => self.xs.last().copied().unwrap_or(self.left),
}
}
/// x of the line's visual end for the paragraph's direction, where its paragraph mark goes:
/// the right edge of the rightmost cluster in a left-to-right paragraph, the left edge of the
/// leftmost in a right-to-left one. Differs from [`Line::end_x`] when the logically last text
/// runs the other way (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one).
pub fn visual_end_x(&self) -> f32 {
let end = self.end_x();
if self.rtl { self.vis.iter().map(|v| v.x).fold(end, f32::min) } else { self.vis.iter().map(|v| v.x + v.w).fold(end, f32::max) }
}
/// The box of the mark ending the line (its paragraph mark or line break) as its left edge and
/// width: [`PARA_MARK_WIDTH`] at the line's visual end, past the text in a left-to-right
/// paragraph and before it in a right-to-left one.
pub fn mark_box(&self) -> (f32, f32) {
let x = self.visual_end_x();
(if self.rtl { x - PARA_MARK_WIDTH } else { x }, PARA_MARK_WIDTH)
}
/// x where the hyphen of a hyphenated line (advance `adv`) is drawn.
pub fn hyphen_x(&self, adv: f32) -> f32 {
match self.vis.last() {
Some(v) if v.is_rtl() => v.x - adv,
Some(v) => v.x + v.w,
None => self.xs.last().copied().unwrap_or(0.0) - adv,
}
}
/// Visual extents, left to right, of clusters `k0..k1`: one span on a left-to-right line,
/// possibly several where bidirectional text splits a logical range.
pub fn spans(&self, k0: usize, k1: usize) -> Vec<(f32, f32)> {
let (k0, k1) = (k0.max(self.c0), k1.min(self.c1));
if k0 >= k1 {
return Vec::new();
}
if self.vis.is_empty() {
return match (self.cl_left(k0), self.cl_left(k1).or_else(|| self.xs.last().copied())) {
(Some(a), Some(b)) => vec![(a, b)],
_ => Vec::new(),
};
}
let mut boxes: Vec<(f32, f32)> =
self.vis.get(k0 - self.c0..k1 - self.c0).unwrap_or(&[]).iter().filter(|v| v.w > 0.0).map(|v| (v.x, v.x + v.w)).collect();
boxes.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<(f32, f32)> = Vec::new();
for (a, b) in boxes {
match out.last_mut() {
Some(last) if a <= last.1 + 0.01 => last.1 = last.1.max(b),
_ => out.push((a, b)),
}
}
out
}
}
/// The list label drawn on the first line.
#[derive(Clone, Debug)]
pub struct Label {
pub text: String,
/// Maximum line ascent of every shaped font in the label, including its leading.
pub ascent: f32,
/// Style of the label's last run (the suffix space and the bidi tests measure with it).
pub style: u32,
pub glyphs: Vec<Glyph>,
/// Font runs over `glyphs`, in drawing order: font fallback can split a label ("1、" takes
/// the digit from the Latin face and the comma from a CJK one), and each glyph id only means
/// something in the face it was shaped with.
pub runs: Vec<LabelRun>,
pub x: f32,
pub width: f32,
}
/// Ruby text laid out over its base text's cluster.
#[derive(Clone, Debug)]
pub struct RubyLayout {
/// The base text's cluster (the one cluster standing for an [`InlineObject::Ruby`]).
pub cluster: usize,
/// Glyphs with their style (index into `ParaLayout::styles`); `dx` is from the cluster's left
/// edge.
pub glyphs: Vec<(u32, Glyph)>,
/// The ruby text and each glyph's byte range in it.
pub text: String,
pub ranges: Vec<std::ops::Range<usize>>,
/// The ruby's baseline above the base text's, points.
pub raise: f32,
}
/// One font run of a list label.
#[derive(Clone, Debug)]
pub struct LabelRun {
/// Index into `ParaLayout::styles`.
pub style: u32,
/// Glyph range in `Label::glyphs`.
pub glyphs: std::ops::Range<usize>,
/// The run's characters (for the PDF text layer).
pub text: String,
}
/// A laid-out paragraph.
#[derive(Clone, Debug)]
pub struct ParaLayout {
pub rp: ResolvedPara,
pub styles: Vec<StyleRun>,
pub glyphs: Vec<Glyph>,
pub clusters: Vec<Cluster>,
pub lines: Vec<Line>,
pub label: Option<Label>,
/// Sum of line heights (without space before/after).
pub height: f32,
pub text_len: usize,
/// Paragraph contains fields whose text depends on the page.
pub has_page_fields: bool,
/// Note references (part id) in this paragraph, by cluster index.
pub notes: Vec<(usize, u32)>,
/// Proofing issues: (byte start, byte end, grammar?).
pub issues: Vec<(usize, usize, bool)>,
/// Drop cap: (clusters at the start that form it, lines it drops, width with its gap).
pub drop_cap: Option<(usize, u8, f32)>,
/// Clusters after which the line may break with a hyphen (soft hyphens, auto hyphenation), sorted.
pub hyph_after: Vec<u32>,
/// Bidi embedding level of each byte of the paragraph text (UAX #9, before the per-line
/// rule L1). Empty when the paragraph is plain left-to-right text.
pub bidi_levels: Vec<u8>,
/// Width of the drop cap letter itself (without the gap to the text).
pub drop_cap_w: f32,
/// Laid-out equations by object index.
pub maths: Vec<(usize, Arc<crate::math::MathLayout>)>,
/// Ruby (Phonetic Guide) text over base-text clusters, in cluster order.
pub rubies: Vec<RubyLayout>,
/// Display equations (on lines of their own): cluster index and justification.
pub displays: Vec<(usize, wordcraft_doc::math::MathJc)>,
/// Byte ranges of the text laid out as nothing ([`left_out`]): hidden text that is not shown,
/// tracked deletions in the final text. Resolved as laid out, table formatting included.
pub left_out: Vec<std::ops::Range<usize>>,
/// The text drawn by clusters that stand for an object (field results, note numbers,
/// equations) rather than for the paragraph's own text, by cluster index, sorted, with each of
/// the cluster's glyphs' byte range in that text (a ligature's glyph spans its letters).
pub shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
/// Shapes drawn around overstruck field text (enclosed characters, `EQ \o`), by cluster index.
pub enclosures: Vec<(usize, Enclosed)>,
}
/// A shape drawn over a cluster (an enclosed character's circle, square, triangle or diamond),
/// relative to the cluster's left edge and baseline, in the cluster's text colour.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Enclosed {
pub kind: wordcraft_doc::para::ShapeKind,
/// Left edge from the cluster's x; top above the baseline (up is positive), points.
pub x: f32,
pub top: f32,
pub w: f32,
pub h: f32,
pub stroke: f32,
}
/// What a table style gives the text of a cell: its paragraph and run formatting, with the
/// cell's conditional formatting (header row, first column…) applied. It sits between document
/// defaults and the paragraph's style (`StyleSheet::resolve_para_in`, `resolve_char_in`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CellText {
pub para: ParaProps,
pub chr: CharProps,
}
/// Inputs that change a paragraph's layout beyond its own content.
pub struct ParaEnv<'a> {
pub doc: &'a Document,
/// Column width, points.
pub width: f32,
pub label: Option<(String, Level)>,
pub fields: &'a FieldCtx,
pub show_hidden: bool,
/// Show field codes instead of results ([`crate::LayoutOptions::field_codes`]).
pub field_codes: bool,
/// Leave tracked deletions out, like hidden text (the final, "No Markup" text).
pub hide_deleted: bool,
/// The table style's formatting for text in this paragraph's cell (`None` outside tables).
pub table: Option<&'a CellText>,
/// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles.
pub proofing: Option<crate::ProofOptions>,
/// How digits look on screen (an unresolved `System` shows Western digits).
pub numeral: NumeralMode,
/// Areas text must flow around (floating objects), relative to the paragraph: x from the
/// column's left edge, y from the top of the first line.
pub exclusions: &'a [Exclusion],
/// Automatic equation numbers used before this paragraph.
pub eq_number: u32,
pub read_line_spacing: Option<f32>,
/// The section's document grid (`Grid::default()`: none).
pub grid: Grid,
/// Shrink inline drawings wider than the line to fit it: in text boxes, which don't clip what
/// runs past their sides. Elsewhere such a drawing keeps its size and the page edge clips it.
pub fit_objects: bool,
}
/// The section's document grid as layout applies it (`w:docGrid`, ECMA-376 §17.6.5).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Grid {
/// Line pitch, points: line heights snap to multiples of it.
pub line: Option<f32>,
/// Character pitch, points: East Asian characters advance by it.
pub chars: Option<f32>,
/// The default font size the character pitch is based on, points.
pub font_size: f32,
/// `snapToChars`: all text snaps to the character grid, not just East Asian characters.
pub snap_all: bool,
}
impl Grid {
/// The grid of section `s` of `doc` (its default font size from the Normal style).
pub fn of(doc: &Document, s: &wordcraft_doc::SectionProps) -> Grid {
let Some(line) = s.grid_line_pitch() else { return Grid::default() };
let size = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size;
let font_size = if size.is_finite() { size.clamp(1.0, 1584.0) } else { 10.5 };
let chars = s.grid_char_pitch(font_size);
let snap_all = s.doc_grid.is_some_and(|g| g.kind == wordcraft_doc::section::DocGridType::SnapToChars);
Grid { line: Some(line), chars, font_size, snap_all }
}
/// A cache key for paragraph layouts.
pub fn key(&self) -> (u32, u32, u32, bool) {
(self.line.map_or(0, f32::to_bits), self.chars.map_or(0, f32::to_bits), self.font_size.to_bits(), self.snap_all)
}
/// `h` rounded up to whole line pitches (unchanged without a line grid).
pub fn snap_height(&self, h: f32) -> f32 {
match self.line {
Some(p) if h.is_finite() => ((h / p - 0.001).ceil().clamp(1.0, 10_000.0) * p).min(1e6),
_ => h,
}
}
}
/// East Asian characters the character grid places on its pitch: CJK ideographs, kana, Hangul,
/// CJK punctuation and full-width forms.
pub fn is_east_asian(c: char) -> bool {
matches!(c as u32,
0x1100..=0x11FF | 0x2E80..=0x2FDF | 0x2FF0..=0x303F | 0x3040..=0x30FF | 0x3100..=0x312F | 0x3130..=0x318F
| 0x3190..=0x31FF | 0x3200..=0x4DBF | 0x4E00..=0x9FFF | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF
| 0xFE30..=0xFE4F | 0xFF01..=0xFF60 | 0xFFE0..=0xFFE6 | 0x20000..=0x3FFFF)
}
/// Character grid: East Asian characters (all text with `snapToChars`) take whole cells of the
/// pitch, the glyph centred in them. A character at the default size takes one cell, a larger one
/// as many as its size needs; Latin text stays proportional unless everything snaps.
fn snap_chars(b: &mut Builder, text: &str, grid: &Grid) {
let Some(pitch) = grid.chars else { return };
let base = grid.font_size.max(1.0);
for c in b.clusters.iter_mut() {
if !matches!(c.kind, ClKind::Text | ClKind::Space) || c.adv <= 0.0 {
continue;
}
let Some(ch) = text.get(c.start..).and_then(|t| t.chars().next()) else { continue };
let east_asian = is_east_asian(ch);
if !(east_asian || (grid.snap_all && c.kind == ClKind::Text)) {
continue;
}
let size = b.styles.get(c.style as usize).map_or(base, |st| st.size);
let cells = if east_asian { (size / base).round() } else { (c.adv / pitch - 0.01).ceil() }.clamp(1.0, 64.0);
let adv = cells * pitch;
let shift = (adv - c.adv) / 2.0;
for g in b.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += shift;
}
c.adv = adv;
}
}
/// An area text wraps around.
#[derive(Clone, Debug, PartialEq)]
pub struct Exclusion {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
/// Text only above and below (no text beside it).
pub top_bottom: bool,
/// Tight and Through wrapping: the outline text follows inside the area (relative to its
/// top-left); `None` = the whole area.
pub contour: Option<Arc<crate::contour::Contour>>,
/// The sides of the object text may go on (Square, Tight, Through).
pub side: WrapText,
}
struct Builder<'a> {
env: &'a ParaEnv<'a>,
styles: Vec<StyleRun>,
/// Hash of a style's format → (index in `styles`, small caps), see `style`.
style_index: std::collections::HashMap<u64, Vec<(u32, bool)>>,
glyphs: Vec<Glyph>,
clusters: Vec<Cluster>,
/// Bidi level per byte of the paragraph text (empty: all left to right).
levels: Vec<u8>,
/// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`).
shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
}
/// What a piece of text is shaped as: one face, case, script formatting and direction.
#[derive(Clone, Copy)]
struct SegKey {
face: Option<FaceRef>,
small: bool,
/// Formatted with the complex-script properties (Persian, Arabic, Hebrew…).
complex: bool,
/// Odd bidi level: shaped right to left.
rtl: bool,
}
impl SegKey {
fn same(&self, o: &SegKey) -> bool {
self.face.map(|f| f.id()) == o.face.map(|f| f.id()) && self.small == o.small && self.complex == o.complex && self.rtl == o.rtl
}
}
/// The first character of `c`'s capital (ß → S): the shaper's one-to-one mapping, and the
/// text read back for labels and field results, whose byte ranges can't grow.
fn upper_char(c: char) -> char {
c.to_uppercase().next().unwrap_or(c)
}
/// The character drawn for `c` in a run formatted `rc`: All Caps capitalises every letter,
/// Small Caps the lowercase ones (as the shaper draws them). The PDF text layer uses it so
/// the text read back is the text shown (#1473).
pub(crate) fn caps_char(rc: &ResolvedChar, c: char) -> char {
if rc.caps || (rc.small_caps && c.is_lowercase()) { upper_char(c) } else { c }
}
/// Appends the text drawn for `c` in a run formatted `rc`: like [`caps_char`], but with a
/// letter's full capital (ß → SS), as body text draws it (#1581).
pub(crate) fn push_caps(rc: &ResolvedChar, c: char, out: &mut String) {
if rc.caps || (rc.small_caps && c.is_lowercase()) { out.extend(c.to_uppercase()) } else { out.push(c) }
}
/// Whether `c`'s capital is more than one character (ß → SS), so All Caps draws more
/// characters than the document holds.
fn caps_expands(c: char) -> bool {
c.to_uppercase().nth(1).is_some()
}
/// Combining marks and joiners (bidi classes NSM, BN: harakat, ZWNJ, ZWJ) belong to the
/// character before them: same font, same shaping run, so Arabic joining isn't broken.
fn is_mark(c: char) -> bool {
use unicode_bidi::BidiClass::{BN, NSM};
matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control()
}
/// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669).
fn is_numeral_digit(c: char) -> bool {
c.is_ascii_digit() || ('\u{660}'..='\u{669}').contains(&c)
}
/// Characters skipped when looking for the text around a number: digits, spaces and the
/// Arabic decimal/thousands separators (٫ ٬).
fn is_number_inside(c: char) -> bool {
is_numeral_digit(c) || ('\u{6f0}'..='\u{6f9}').contains(&c) || c == ' ' || c == '\t' || c == '\u{66b}' || c == '\u{66c}'
}
/// Which digits of run `text` sit in right-to-left surroundings (Word › Numeral › Context), by
/// byte offset. European numbers take an even bidi level, so a digit's own level says little;
/// the nearest neighbour on either side that isn't part of the number (digits, spaces,
/// separators) decides. One pass each way, so long runs of digits and spaces stay linear.
/// Offsets past `levels` count as left to right.
fn context_digit_dirs(text: &str, levels: &[u8], base: usize) -> Vec<bool> {
let odd = |o: usize| levels.get(base.saturating_add(o)).is_some_and(|l| l % 2 == 1);
let mut out = vec![false; text.len()];
let mut left_rtl = false;
for (o, c) in text.char_indices() {
if !is_number_inside(c) {
left_rtl = odd(o);
} else if is_numeral_digit(c)
&& let Some(v) = out.get_mut(o)
{
*v = odd(o) || left_rtl;
}
}
let mut right_rtl = false;
for (o, c) in text.char_indices().rev() {
if !is_number_inside(c) {
right_rtl = odd(o);
} else if right_rtl
&& is_numeral_digit(c)
&& let Some(v) = out.get_mut(o)
{
*v = true;
}
}
out
}
/// Map a byte range of numeral-substituted display text back to the original text.
///
/// `map_starts[i]` is the display byte offset of the `i`th char and `orig_of[i]` its original
/// byte offset (`orig_of` ends with the original length as a sentinel). Substitution is
/// 1 char → 1 char, so both ends land on char boundaries; anything else falls back to the
/// end of the original text instead of panicking or slicing mid-character.
pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usize, ms: usize, me: usize) -> (usize, usize) {
let n = map_starts.len();
let i = map_starts.partition_point(|&b| b < ms).min(n);
let j = map_starts.partition_point(|&b| b < me).min(n);
let s = orig_of.get(i).copied().unwrap_or(orig_len);
let e = orig_of.get(j).copied().unwrap_or(orig_len);
(s.min(e), s.max(e))
}
/// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the
/// paragraph is left to right and has no right-to-left characters.
pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec<u8> {
if !rtl_para && !text.chars().any(wordcraft_fonts::is_rtl) {
return Vec::new();
}
let level = if rtl_para { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() };
let info = unicode_bidi::ParagraphBidiInfo::new(text, Some(level));
info.levels.iter().map(|l| l.number()).collect()
}
/// Rule L2 of UAX #9: the visual order (left to right) of items with these levels: from the
/// highest level down to the lowest odd one, reverse every run at that level or above.
pub fn visual_order(levels: &[u8]) -> Vec<usize> {
let mut order: Vec<usize> = (0..levels.len()).collect();
let Some(min_odd) = levels.iter().copied().filter(|l| l % 2 == 1).min() else { return order };
let max = levels.iter().copied().max().unwrap_or(0);
let level_at = |order: &[usize], i: usize| order.get(i).and_then(|&o| levels.get(o)).copied().unwrap_or(0);
let mut lvl = max;
while lvl >= min_odd {
let mut i = 0;
while i < order.len() {
if level_at(&order, i) >= lvl {
let mut j = i;
while j < order.len() && level_at(&order, j) >= lvl {
j += 1;
}
if let Some(run) = order.get_mut(i..j) {
run.reverse();
}
i = j;
} else {
i += 1;
}
}
if lvl == 0 {
break;
}
lvl -= 1;
}
order
}
/// Text Word sets in the East Asian font: CJK ideographs, kana, bopomofo, CJK punctuation and
/// fullwidth forms, and Hangul.
fn east_asian(c: char) -> bool {
crate::kinsoku::is_east_asian(c) || matches!(u32::from(c), 0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF)
}
/// Word draws a font that isn't installed in Cambria when the document's font table classes it
/// as roman (measured in Word; Calibri otherwise, which our own substitutes already match).
const MISSING_ROMAN_FONT: &str = "Cambria";
/// The family to look `font` up as: [`MISSING_ROMAN_FONT`] for a missing roman font, else `font`.
fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
let db = wordcraft_fonts::FontDb::global();
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
if roman && !db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) { MISSING_ROMAN_FONT } else { font }
}
impl<'a> Builder<'a> {
/// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one
/// char out. `rtl` is the shaping direction; in Context mode `context` (from
/// [`context_digit_dirs`], empty otherwise) gives each digit's surroundings, since European
/// numbers take an even bidi level.
fn numeral_char(&self, context: &[bool], at: usize, c: char, rtl: bool) -> char {
let dir = if !context.is_empty() && is_numeral_digit(c) { context.get(at).copied().unwrap_or(false) } else { rtl };
self.env.numeral.map(c, dir)
}
/// Style index for a resolved char in `face` (caps-scaled `size` override for small caps).
fn style(&mut self, rc: &Arc<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u32 {
let r = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic);
let face = face_override.unwrap_or(r.face);
// The display list reads every run's format (link target, strike kind, underline colour,
// baseline shift…) from its StyleRun, so runs share one only when their formats are equal.
// Keyed by a hash of the commonly varying fields (no allocation: this runs for every shaped
// piece), then confirmed by comparing whole formats within the bucket.
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(face.id(), small, &rc.font, rc.size.to_bits(), rc.bold, rc.italic, rc.color, rc.highlight, rc.shading).hash(&mut h);
(rc.underline_color, rc.strike, rc.double_strike, &rc.link, rc.ins, rc.del, rc.hidden).hash(&mut h);
h.finish()
};
let bucket = self.style_index.entry(key).or_default();
// Formats that never compare equal (a NaN from a hostile file) get their own styles; a capped
// scan keeps such a bucket from turning quadratic.
for &(i, s) in bucket.iter().rev().take(64) {
if s == small
&& let Some(st) = self.styles.get(i as usize)
&& st.face.id() == face.id()
&& (Arc::ptr_eq(&st.rc, rc) || *st.rc == **rc)
{
return i;
}
}
let size = rc.draw_size() * if small { 0.8 } else { 1.0 };
let (a, d) = wordcraft_fonts::word::line_metrics(&face);
let k = size as f64 / face.upem.max(1.0);
// A fallback face (letters the requested font lacks) is emboldened or slanted when it has
// no bold or italic of its own.
let (synth_bold, synth_italic) = match face_override {
None => (r.synth_bold, r.synth_italic),
Some(f) => (rc.bold && f.get().weight < 600.0, rc.italic && !f.get().italic),
};
let st = StyleRun {
face,
size,
synth_bold,
synth_italic,
rc: rc.clone(),
ascent: (a * k) as f32,
descent: (d * k) as f32,
shift: rc.baseline_shift(),
};
// A `u32` index (like the glyph ranges) counts more distinct formats than a paragraph can
// hold in memory, so every format keeps its own style.
let i = u32::try_from(self.styles.len()).unwrap_or(u32::MAX);
self.styles.push(st);
self.style_index.entry(key).or_default().push((i, small));
i
}
/// Shape `text` (a piece of the paragraph starting at byte `base`) in one style and append clusters.
fn shape(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>) {
self.shape_with(text, base, rc, kind_override, true);
}
/// `para_text`: `text` is paragraph text at `base`, so the paragraph's bidi levels apply
/// (false for field results and list labels, which are shaped on their own).
fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, para_text: bool) {
if text.is_empty() {
return;
}
// Complex-script characters (Persian, Arabic…) use the run's complex-script font, size,
// bold and italic.
let rc_cs: Option<Arc<ResolvedChar>> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex()));
let resolved = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic);
let primary = resolved.face;
// Symbol-font characters are laid out as their Unicode equivalents.
let sym = SymbolRun::of(&rc.font, &resolved);
let show = |c: char| sym.map_or(c, |m| m.show(c));
let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &r.font), r.bold, r.italic).face);
// Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when
// it is installed and has them.
let primary_ea = rc
.font_ea
.as_ref()
.filter(|f| !f.eq_ignore_ascii_case(&rc.font) && text.chars().any(east_asian))
.map(|f| wordcraft_fonts::word::resolve(f, rc.bold, rc.italic))
.filter(|r| !r.substituted)
.map(|r| r.face);
let use_levels = para_text && !self.levels.is_empty();
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() };
// One fallback face for the run's East Asian characters: the first face in fallback order
// covering every character the primary font lacks, so the run draws in a single CJK face
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
// ideographs beside a Simplified-Chinese sans for the rest). Whitespace and characters the
// primary covers don't constrain the choice. `None` keeps the per-character fallback.
let ea_face: Option<FaceRef> = {
let ea: Vec<char> = text
.chars()
.filter(|c| crate::kinsoku::is_east_asian(*c) && !primary.covers(*c) && !c.is_whitespace() && !c.is_control() && *c != SOFT_HYPHEN)
.collect();
if ea.is_empty() {
None
} else {
wordcraft_fonts::FontDb::global().fallback_for_east_asian(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
}
};
// Split by font coverage (fallback faces), small caps case, script formatting and direction.
let mut seg_start = 0;
let mut cur: Option<SegKey> = None;
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
for (i, c) in text.char_indices() {
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
// A symbol-font character becomes its Unicode symbol (#776).
let c = show(c);
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
// fallback face are picked for the displayed char, so Hindi digits land in an
// Arabic-capable face and Western digits in a Latin one.
let dc = self.numeral_char(&context, i, c, rtl);
let mark = is_mark(c);
let complex = match cur {
Some(k) if mark => k.complex,
_ => rc_cs.is_some() && rc.uses_complex(dc),
};
let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };
let face = match cur {
Some(k) if mark => k.face,
_ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
_ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
// Stay in the fallback face the word started in while it covers the letters.
Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
// The run's unified East Asian face, when one face covers the whole run.
_ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
_ => {
let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
}
};
let small = rc.small_caps && !rc.caps && c.is_lowercase();
let k = SegKey { face, small, complex, rtl };
match cur {
Some(p) if p.same(&k) => {}
Some(p) => {
segs.push((seg_start, i, p));
seg_start = i;
cur = Some(k);
}
None => cur = Some(k),
}
}
if let Some(p) = cur {
segs.push((seg_start, text.len(), p));
}
let no_kern = [wordcraft_fonts::feature_value(b"kern", 0)];
for (a, b, key) in segs {
let Some(sub) = text.get(a..b) else { continue };
let rc = match (&rc_cs, key.complex) {
(Some(cs), true) => cs.clone(),
_ => rc.clone(),
};
let rc = &rc;
let small = key.small;
let si = self.style(rc, key.face, small);
let Some(st) = self.styles.get(si as usize).cloned() else { continue };
let upper = rc.caps || small;
// All Caps and Small Caps draw a letter's full capital, which can be several
// characters (ß → SS, ff → FF, ʼn → ʼN), as Word does (#1581). A symbol-font run
// draws its codes as symbols, never as expanded capitals.
let expands = sym.is_none() && upper && sub.chars().any(caps_expands);
// Displayed text: numeral substitution is 1 char → 1 char and a capital may be
// several; `map_starts` holds where each document char's displayed form starts and
// `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel).
let substituted = expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);
let mut mapped = String::new();
let mut map_starts: Vec<usize> = Vec::new();
let mut orig_of: Vec<usize> = Vec::new();
if substituted {
mapped = String::with_capacity(sub.len().saturating_add(8));
for (i, c) in sub.char_indices() {
map_starts.push(mapped.len());
orig_of.push(i);
let d = self.numeral_char(&context, a + i, c, key.rtl);
if expands {
mapped.extend(d.to_uppercase());
} else {
mapped.push(d);
}
}
orig_of.push(sub.len());
}
let shape_text = if substituted { mapped.as_str() } else { sub };
let map = |c: char| {
let d = show(c);
if d != c {
d
} else if upper {
upper_char(c)
} else {
c
}
};
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern };
let shaped = if use_levels {
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
} else {
wordcraft_fonts::shape(&st.face, shape_text, features, map)
};
let k = st.size / st.face.upem.max(1.0) as f32;
let hscale = rc.scale / 100.0;
// Group glyphs by cluster byte offset; graphemes may span several shaper clusters.
// Complex-script syllables the graphemes split (Myanmar vowel signs, stacked
// consonants) stay one cluster: the caret never stops inside them.
let mut bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(shape_text);
if expands {
// The capitals drawn for one character (the two S of ß) are one cluster: the
// caret, selection and line breaking never stop inside the character.
bounds.retain(|b| map_starts.binary_search(b).is_ok());
}
let mut gi = 0usize;
for (bi, &gs) in bounds.iter().enumerate() {
let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len());
let g0 = self.glyphs.len() as u32;
let mut adv = 0.0f32;
while let Some(g) = shaped.get(gi) {
if g.cluster >= ge {
break;
}
self.glyphs.push(Glyph { gid: g.gid, dx: adv + g.x_offset as f32 * k * hscale, dy: g.y_offset as f32 * k });
adv += g.x_advance as f32 * k * hscale;
gi += 1;
}
let g1 = self.glyphs.len() as u32;
let s = shape_text.get(gs..ge).unwrap_or("");
let ch = s.chars().next().unwrap_or(' ');
let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' {
ClKind::Space
} else if ch == SOFT_HYPHEN {
ClKind::Marker
} else {
ClKind::Text
});
let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing };
// Clusters index the document text, never the displayed text.
let (cs, ce) = if substituted { remap_range(&map_starts, &orig_of, sub.len(), gs, ge) } else { (gs, ge) };
self.clusters.push(Cluster {
start: base + a + cs,
end: base + a + ce,
adv,
kind,
style: si,
g0,
g1: if kind == ClKind::Marker { g0 } else { g1 },
break_after: false,
obj_h: 0.0,
obj_d: 0.0,
dot: s == "." || s == ",",
});
}
}
}
/// One cluster for a whole object with its display text (fields, note references).
fn shape_atomic(&mut self, text: &str, start: usize, end: usize, rc: &Arc<ResolvedChar>) {
let before = self.clusters.len();
self.shape_with(text, start, rc, None, false);
let added: Vec<Cluster> = self.clusters.drain(before..).collect();
let Some(first) = added.first() else {
let si = self.style(rc, None, false);
let g = self.glyphs.len() as u32;
self.clusters.push(Cluster {
start,
end,
adv: 0.0,
kind: ClKind::Marker,
style: si,
g0: g,
g1: g,
break_after: false,
obj_h: 0.0,
obj_d: 0.0,
dot: false,
});
return;
};
// Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's
// text (a ligature's glyph takes the letters of the clusters after it that have none).
let mut x = 0.0;
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
for c in &added {
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += x;
}
x += c.adv;
let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start));
if c.g1 <= c.g0 {
let from = ranges.last().map(|r| r.start);
for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) {
r.end = b;
}
}
ranges.extend((c.g0..c.g1).map(|_| a..b));
}
let g0 = first.g0;
let style = first.style;
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
self.shown.push((self.clusters.len(), text.to_string(), ranges));
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
}
/// Shape `texts` on top of one another as one cluster for `start..end`: each aligned within
/// the cluster (at least `width` wide) and raised `dy`. Returns the cluster's index.
#[allow(clippy::too_many_arguments)]
fn shape_overstruck(
&mut self,
texts: &[&str],
start: usize,
end: usize,
rc: &Arc<ResolvedChar>,
align: wordcraft_doc::eq::OAlign,
width: f32,
dy: f32,
) -> Option<usize> {
use wordcraft_doc::eq::OAlign;
let (c0, s0) = (self.clusters.len(), self.shown.len());
for t in texts {
self.shape_atomic(t, start, end, rc);
}
let added: Vec<Cluster> = self.clusters.drain(c0..).collect();
let shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)> = self.shown.drain(s0..).collect();
let adv = added.iter().filter(|c| c.kind == ClKind::Text).map(|c| c.adv).fold(width.max(0.0), f32::max);
let (mut text, mut ranges) = (String::new(), Vec::new());
let (mut g0, mut g1) = (u32::MAX, 0u32);
for (ci, t, r) in &shown {
let Some(c) = added.get(ci.saturating_sub(c0)) else { continue };
let dx = match align {
OAlign::Left => 0.0,
OAlign::Center => (adv - c.adv) / 2.0,
OAlign::Right => adv - c.adv,
};
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += dx;
g.dy += dy;
}
let off = text.len();
ranges.extend(r.iter().map(|x| x.start + off..x.end + off));
text.push_str(t);
g0 = g0.min(c.g0);
g1 = g1.max(c.g1);
}
let g = self.glyphs.len() as u32;
let (g0, g1) = if g0 <= g1 { (g0, g1) } else { (g, g) };
let style = match added.iter().find(|c| c.kind == ClKind::Text) {
Some(c) => c.style,
None => self.style(rc, None, false),
};
let k = self.clusters.len();
if !text.is_empty() {
self.shown.push((k, text, ranges));
}
self.clusters.push(Cluster { start, end, adv, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
Some(k)
}
}
/// Lay out an `EQ \o` field (ECMA-376 Part 1 §17.16.5.20): its arguments drawn on top of one
/// another, as one cluster. An enclosure symbol among them (○ □ △ ◇, see
/// [`wordcraft_doc::eq::EncloseShape`]) is drawn as a shape around the text rather than as a
/// glyph, so it never depends on the font: "shrink text" scales the text to fit inside a shape
/// one em high, "enlarge symbol" grows the shape around text at its own size.
fn overstrike(
b: &mut Builder,
o: &wordcraft_doc::eq::Overstrike,
instr: &str,
start: usize,
end: usize,
rc: &Arc<ResolvedChar>,
enclosures: &mut Vec<(usize, Enclosed)>,
) {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
use wordcraft_doc::para::ShapeKind;
let shapes: Vec<EncloseShape> = o.args.iter().filter_map(|a| EncloseShape::from_symbol(a)).collect();
let texts: Vec<&str> = o.args.iter().filter(|a| !a.is_empty() && EncloseShape::from_symbol(a).is_none()).map(String::as_str).collect();
let Some(shape) = shapes.first().copied() else {
b.shape_overstruck(&texts, start, end, rc, o.align, 0.0, 0.0);
return;
};
let style = Enclosure::parse(instr).map(|e| e.style).unwrap_or_default();
let s = rc.size.clamp(1.0, 1638.0);
// The widest text at the run's own size.
let (c0, g0, s0) = (b.clusters.len(), b.glyphs.len(), b.shown.len());
for t in &texts {
b.shape_atomic(t, start, end, rc);
}
let w0 = b.clusters.get(c0..).map(|cs| cs.iter().map(|c| c.adv).fold(0.0f32, f32::max)).unwrap_or(0.0).max(0.0);
b.clusters.truncate(c0);
b.glyphs.truncate(g0);
b.shown.truncate(s0);
// The shape's box (`bw` × `bh`, centred `cy` above the baseline) and the text's size.
let cy = s * 0.34;
let (bw, bh, size) = match style {
EncloseStyle::Shrink => {
// Room for the text inside each shape: (height, width) as fractions of the box.
let (fh, fw) = match shape {
EncloseShape::Circle => (0.62, 0.7),
EncloseShape::Square => (0.68, 0.78),
EncloseShape::Triangle => (0.45, 0.5),
EncloseShape::Diamond => (0.5, 0.55),
};
let bh = s * 1.05;
let mut t = s * fh;
if w0 > 0.0 && w0 * t / s > bh * fw {
t = bh * fw * s / w0;
}
(bh, bh, t.max(s * 0.2))
}
EncloseStyle::Enlarge => {
let (kh, kw) = match shape {
EncloseShape::Circle => (1.4, 1.4),
EncloseShape::Square => (1.3, 1.3),
EncloseShape::Triangle => (1.75, 2.0),
EncloseShape::Diamond => (1.7, 1.8),
};
let bh = s * kh;
(bh.max(w0 * kw), bh, s)
}
};
// A triangle's middle is low; the text sits there.
let ty = if shape == EncloseShape::Triangle { cy - bh * 0.13 } else { cy };
let trc = if (size - rc.size).abs() > 0.01 {
let mut r = (**rc).clone();
r.size = size;
Arc::new(r)
} else {
rc.clone()
};
let pad = s * 0.05;
let Some(k) = b.shape_overstruck(&texts, start, end, &trc, o.align, bw + 2.0 * pad, ty - size * 0.34) else { return };
let adv = b.clusters.get(k).map(|c| c.adv).unwrap_or(bw);
let kind = match shape {
EncloseShape::Circle => ShapeKind::Ellipse,
EncloseShape::Square => ShapeKind::Rectangle,
EncloseShape::Triangle => ShapeKind::Triangle,
EncloseShape::Diamond => ShapeKind::Diamond,
};
let stroke = (s * 0.06).clamp(0.4, 4.0);
enclosures.push((k, Enclosed { kind, x: (adv - bw) / 2.0, top: cy + bh / 2.0, w: bw, h: bh, stroke }));
}
impl Builder<'_> {
/// One cluster for ruby `base` text (an [`InlineObject::Ruby`] at `start..end`), as wide as the
/// wider of the base and the ruby, with the ruby text laid out over it by `align`. The line
/// grows to fit the ruby through the cluster's `obj_h`.
#[allow(clippy::too_many_arguments)]
fn ruby(
&mut self,
base: &str,
ruby: &str,
align: RubyAlign,
raise: f32,
start: usize,
end: usize,
rc: &Arc<ResolvedChar>,
ruby_rc: &Arc<ResolvedChar>,
) -> Option<RubyLayout> {
self.shape_atomic(base, start, end, rc);
let ci = self.clusters.len().checked_sub(1)?;
let (bc, bg) = (self.clusters.len(), self.glyphs.len());
self.shape_with(ruby, 0, ruby_rc, None, false);
let cl: Vec<Cluster> = self.clusters.drain(bc..).collect();
let ruby_w: f32 = cl.iter().map(|c| c.adv).sum();
let base_w = self.clusters.get(ci).map_or(0.0, |c| c.adv);
let w = base_w.max(ruby_w);
let n = cl.len().max(1) as f32;
// Where the narrower text starts, and the extra gap between its letters (ruby only).
let lead = |room: f32| match align {
RubyAlign::Left => 0.0,
RubyAlign::Right | RubyAlign::RightVertical => room,
_ => room / 2.0,
};
let (x0, gap) = match align {
RubyAlign::DistributeLetter if cl.len() > 1 => (0.0, (w - ruby_w) / (n - 1.0)),
RubyAlign::DistributeSpace => ((w - ruby_w) / n / 2.0, (w - ruby_w) / n),
_ => (lead(w - ruby_w), 0.0),
};
let mut glyphs = Vec::new();
let mut ranges = Vec::new();
let mut x = x0;
for c in &cl {
for g in self.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
glyphs.push((c.style, Glyph { dx: g.dx + x, ..*g }));
ranges.push(c.start..c.end);
}
x += c.adv + gap;
}
self.glyphs.truncate(bg);
// The base text centres (or aligns) under a wider ruby.
let shift = lead(w - base_w);
let ascent = cl.iter().filter_map(|c| self.styles.get(c.style as usize)).map(|s| s.ascent).fold(0.0f32, f32::max);
let c = self.clusters.get_mut(ci)?;
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += shift;
}
c.adv = w;
c.obj_h = raise + ascent;
Some(RubyLayout { cluster: ci, glyphs, text: ruby.to_string(), ranges, raise })
}
}
/// Whether text with this formatting is left out of the layout: hidden text unless it is shown, and
/// tracked deletions in the final text. It takes no room, draws nothing and is no place to break.
pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) -> bool {
(rc.hidden && !show_hidden) || (rc.del.is_some() && hide_deleted)
}
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let doc = env.doc;
let mut rp = doc.styles.resolve_para_in(&p.props, env.table.map(|t| &t.para));
if let Some(spacing) = env.read_line_spacing.filter(|s| s.is_finite() && (1.0..=2.0).contains(s)) {
rp.line_spacing = wordcraft_doc::props::LineSpacing::Multiple(spacing);
}
let para_style = p.props.style.as_deref();
let table_chr = env.table.map(|t| &t.chr);
// Indents in characters are measured in the paragraph mark's font size.
if rp.indent_chars.iter().any(|c| *c != 0.0) {
rp.set_char_unit(doc.styles.resolve_char_in(para_style, table_chr, &p.mark).size);
}
// List level indents apply unless the paragraph sets its own, or, for a list from the style,
// the style giving the list (or one based on it) does (`indents_over_list`). A paragraph
// that switches its style's list off (`num: 0`) drops the styles' indents, as Word does.
let own_left = p.props.indent_left.is_some() || p.props.indent_left_chars.is_some_and(|c| c != 0.0);
let own_first = p.props.indent_first.is_some() || p.props.indent_first_chars.is_some_and(|c| c != 0.0);
if let Some((_, lvl)) = &env.label {
let (style_left, style_first) =
if p.props.numbering.is_some() { (false, false) } else { doc.styles.indents_over_list(para_style.unwrap_or("Normal")) };
if !own_left && !style_left {
rp.indent_left = lvl.indent;
}
if !own_first && !style_first {
rp.indent_first = -lvl.hanging;
}
} else if p.props.numbering.is_some_and(|n| n.num == 0) {
if !own_left {
rp.indent_left = 0.0;
}
if !own_first {
rp.indent_first = 0.0;
}
}
let resolve = |c: &CharProps| -> Arc<ResolvedChar> { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) };
let levels = bidi_levels(&p.text, rp.bidi);
let mut b =
Builder { env, styles: Vec::new(), style_index: Default::default(), glyphs: Vec::new(), clusters: Vec::new(), levels, shown: Vec::new() };
let mark_rc = resolve(&p.mark);
let mark_style = b.style(&mark_rc, None, false);
let mut has_page_fields = false;
let mut notes = Vec::new();
let mut obj_index = 0usize;
// Drop cap: the first character, sized so its capital spans `lines` lines.
let drop_first = (rp.drop_cap > 0)
.then(|| p.text.chars().next())
.flatten()
.filter(|c| !matches!(*c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r' | ' '))
.filter(|c| p.text.len() > c.len_utf8());
let mut drop_cap = None;
let mut drop_cap_w = 0.0;
let mut maths = Vec::new();
let mut enclosures = Vec::new();
let mut rubies = Vec::new();
let mut displays = Vec::new();
let mut eq_counter = env.eq_number;
let math_props = doc.settings.math.clone().unwrap_or_default();
// The byte ranges of runs left out of the layout, in order, adjacent runs merged.
let mut left: Vec<std::ops::Range<usize>> = Vec::new();
// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
let mut runs: Vec<ProofRun> = Vec::new();
for (range, props) in p.run_ranges() {
let rc = resolve(props);
runs.push(ProofRun::of(range.clone(), &rc));
let Some(text) = p.text.get(range.clone()) else { continue };
if left_out(&rc, b.env.show_hidden, b.env.hide_deleted) {
match left.last_mut() {
Some(last) if last.end == range.start => last.end = range.end,
_ => left.push(range.clone()),
}
let si = b.style(&rc, None, false);
let g = b.glyphs.len() as u32;
// Word still prints the note of a hidden reference mark (and numbers it); a deleted
// reference is gone from the final text, note and all.
let keeps_notes = !(rc.del.is_some() && b.env.hide_deleted);
for (i, c) in text.char_indices() {
let mut note = None;
if c == OBJ {
if let Some(InlineObject::NoteRef { id, .. }) = p.objects.get(obj_index)
&& keeps_notes
{
note = Some(*id);
}
obj_index += 1;
}
if let Some(id) = note {
notes.push((b.clusters.len(), id));
}
b.clusters.push(Cluster {
start: range.start + i,
end: range.start + i + c.len_utf8(),
adv: 0.0,
kind: ClKind::Marker,
style: si,
g0: g,
g1: g,
break_after: false,
obj_h: 0.0,
obj_d: 0.0,
dot: false,
});
}
continue;
}
let mut seg = 0;
if range.start == 0
&& let Some(c) = drop_first
&& let Some(first) = text.get(..c.len_utf8())
{
let line_h = (rc.size * 1.2).max(1.0);
let lines = rp.drop_cap as f32;
// Capital height is ~0.7 em: the letter's cap spans from line 1's caps to line N's baseline.
let mut big = (*rc).clone();
// `clamp` would panic here when the text itself is larger than 1000 pt.
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).min(1000.0).max(rc.size);
let before = b.clusters.len();
b.shape(first, 0, &Arc::new(big), None);
let w: f32 = b.clusters.get(before..).map(|c| c.iter().map(|c| c.adv).sum()).unwrap_or(0.0);
drop_cap = Some((b.clusters.len() - before, rp.drop_cap, w + rc.size * 0.3));
drop_cap_w = w;
// The letter hangs beside the text; lines flow at the indent `break_lines` adds.
for c in b.clusters.iter_mut().skip(before) {
c.adv = 0.0;
}
seg = c.len_utf8();
}
for (i, c) in text.char_indices() {
if i < seg {
continue;
}
let special = matches!(c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r');
if !special {
continue;
}
if let Some(s) = text.get(seg..i) {
b.shape(s, range.start + seg, &rc, None);
}
seg = i + c.len_utf8();
let (start, end) = (range.start + i, range.start + i + c.len_utf8());
let si = b.style(&rc, None, false);
let g = b.glyphs.len() as u32;
let push = |b: &mut Builder, kind: ClKind, adv: f32, h: f32| {
b.clusters.push(Cluster { start, end, adv, kind, style: si, g0: g, g1: g, break_after: false, obj_h: h, obj_d: 0.0, dot: false })
};
match c {
'\t' => push(&mut b, ClKind::Tab, 0.0, 0.0),
LINE_BREAK | '\r' => push(&mut b, ClKind::LineBreak, 0.0, 0.0),
PAGE_BREAK => push(&mut b, ClKind::PageBreak, 0.0, 0.0),
COLUMN_BREAK => push(&mut b, ClKind::ColumnBreak, 0.0, 0.0),
_ => {
let k = obj_index;
obj_index += 1;
match p.objects.get(k) {
Some(o) if o.is_drawing() => {
// A hidden one takes no room, and with no height its line doesn't draw it.
if let Some((w, h, float)) = o.frame()
&& float.wrap == wordcraft_doc::para::Wrap::Inline
&& !float.hidden
{
// A picture wider than the line keeps its size, as in Word: it runs past
// the margin and the page edge clips it (see `ParaEnv::fit_objects`).
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 };
// The line makes room for effects (shadows) around the picture too, and
// for a rotated one's bounds, as Word does.
let [el, et, er, eb] = float.effect_extent();
let (px, py) = float.spin_pad(w * s, h * s);
push(&mut b, ClKind::Object(k), w * s + el + er + 2.0 * px, h * s + et + eb + 2.0 * py);
} else {
push(&mut b, ClKind::Object(k), 0.0, 0.0);
}
}
Some(InlineObject::Field { instr, result, code, .. }) => {
if env.field_codes != *code {
// The code in braces on grey, like Word's field shading.
let mut shaded = (*rc).clone();
if shaded.highlight.is_none() && shaded.shading.is_none() {
shaded.shading = Some(FIELD_SHADING);
}
b.shape_atomic(&field_code_text(instr), start, end, &Arc::new(shaded));
} else {
match wordcraft_doc::eq::parse_overstrike(instr) {
Some(o) => overstrike(&mut b, &o, instr, start, end, &rc, &mut enclosures),
None => {
let (t, page_dep) = field_text_on(instr, result, env.fields, env.fields.page_at(start));
has_page_fields |= page_dep;
b.shape_atomic(&t, start, end, &rc);
}
}
}
}
Some(InlineObject::NoteRef { kind, id, custom }) => {
let num = if custom.is_empty() {
let fmt = match kind {
NoteKind::Footnote => doc.settings.footnote_format,
NoteKind::Endnote => doc.settings.endnote_format,
};
env.fields.note_mark(*id, fmt)
} else {
custom.clone()
};
let mut sup = (*rc).clone();
sup.vert_align = wordcraft_doc::props::VertAlign::Superscript;
b.shape_atomic(&num, start, end, &Arc::new(sup));
notes.push((b.clusters.len().saturating_sub(1), *id));
}
Some(InlineObject::Equation { linear, display, math }) => {
let avail = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(12.0);
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &math_props, &mut eq_counter);
let st = b.styles.get(si as usize);
// The line is at least as tall as the text around it.
let (a, d) = st.map(|s| (s.ascent, s.descent)).unwrap_or((0.0, 0.0));
b.clusters.push(Cluster {
start,
end,
adv: ml.width,
kind: ClKind::Object(k),
style: si,
g0: g,
g1: g,
break_after: false,
obj_h: ml.ascent.max(a).max(0.01),
obj_d: ml.descent.max(d),
dot: false,
});
if *display {
displays.push((b.clusters.len() - 1, math.jc));
}
maths.push((k, Arc::new(ml)));
}
// A note separator's line: a short one, or one across the column.
Some(o) if o.as_note_separator().is_some() => {
let cont = o.as_note_separator() == Some(true);
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(1.0);
push(&mut b, ClKind::Object(k), if cont { maxw } else { crate::NOTE_SEP_W.min(maxw) }, 0.0);
}
Some(InlineObject::Ruby { base, ruby, align, size, raise, props: ruby_props, .. }) => {
let mut rr = (*resolve(&props.clone().overlaid(ruby_props))).clone();
let size = if size.is_finite() { size.clamp(1.0, 1584.0) } else { rc.size * 0.5 };
(rr.size, rr.size_cs, rr.position) = (size, size, 0.0);
rr.vert_align = wordcraft_doc::props::VertAlign::Baseline;
let raise = if raise.is_finite() { raise.clamp(0.0, 1584.0) } else { rc.size };
if let Some(r) = b.ruby(base, ruby, *align, raise, start, end, &rc, &Arc::new(rr)) {
rubies.push(r);
}
}
Some(InlineObject::Opaque { text, .. }) => b.shape_atomic(text, start, end, &rc),
_ => push(&mut b, ClKind::Marker, 0.0, 0.0),
}
}
}
}
if let Some(s) = text.get(seg..) {
b.shape(s, range.start + seg, &rc, None);
}
}
if rp.snap_to_grid {
snap_chars(&mut b, &p.text, &env.grid);
}
// Break opportunities and hyphenation points come from the text as laid out: hidden text that is
// not shown and, without markup, tracked deletions are left out, so a space or soft hyphen there
// is no place to break, and words are hyphenated as they read.
// The text and, for each char of it, its end there and in `p.text`.
let (text, ends): (std::borrow::Cow<str>, Vec<(usize, usize)>) = if left.is_empty() {
(p.text.as_str().into(), Vec::new())
} else {
let (mut text, mut ends) = (String::new(), Vec::new());
let mut next = left.iter().peekable();
for (i, c) in p.text.char_indices() {
// `left` is in order: skip the ranges that end here, then is `i` in the next one?
while next.next_if(|r| r.end <= i).is_some() {}
if next.peek().is_some_and(|r| r.start <= i) {
continue;
}
text.push(c);
ends.push((text.len(), i + c.len_utf8()));
}
(text.into(), ends)
};
// The end of a char in `text` → the same place in `p.text`.
let to_para = |i: usize| -> Option<usize> {
if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
};
let mut found = Vec::new();
for (i, o) in unicode_linebreak::linebreaks(&text) {
// Word keeps "and/or" and web addresses whole: no break right after a slash (a word
// too long for the line still breaks anywhere).
let after_slash =
text.get(..i).is_some_and(|t| t.ends_with('/')) && text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric);
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len()) && !after_slash {
found.push(i);
}
}
// Myanmar has no spaces between words: it wraps between syllables.
if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) {
found.extend(wordcraft_doc::cluster::myanmar_breaks(&text));
found.sort_unstable();
found.dedup();
}
// Asian typography: kinsoku and breaking Latin words anywhere.
crate::kinsoku::apply(&text, &mut found, rp.kinsoku, rp.word_wrap);
let opps: std::collections::HashSet<usize> = found.into_iter().filter_map(to_para).collect();
for c in &mut b.clusters {
c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab);
}
// List label.
let label = env.label.as_ref().map(|(text, lvl)| {
let lc = p.mark.clone().overlaid(&lvl.chr);
let rc = resolve(&lc);
// A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font
// isn't installed (shaping maps it); the label's text reads as that character too.
let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) };
let before_c = b.clusters.len();
let before_g = b.glyphs.len();
b.shape_with(text, 0, &rc, None, false);
let mut cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
// A right-to-left paragraph's label reads right to left too ("1." shows as ".1").
if rp.bidi {
let lv = bidi_levels(text, true);
let cl_lv: Vec<u8> = cl.iter().map(|c| lv.get(c.start).copied().unwrap_or(1)).collect();
let order = visual_order(&cl_lv);
cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect();
}
let mut glyphs = Vec::new();
let mut runs: Vec<LabelRun> = Vec::new();
let mut ascent = 0.0f32;
let mut x = 0.0;
let mut style = mark_style;
for c in &cl {
style = c.style;
if let Some(st) = b.styles.get(style as usize) {
let a = wordcraft_fonts::word::line_metrics(&st.face).0 * st.size as f64 / st.face.upem.max(1.0);
ascent = ascent.max(a as f32 + st.shift.max(0.0));
}
let g0 = glyphs.len();
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
glyphs.push(Glyph { dx: g.dx + x, ..*g });
}
// A label in an All Caps paragraph reads as the capitals drawn (#1473).
let chars: String = shown(text.get(c.start..c.end).unwrap_or("")).chars().map(|ch| caps_char(&rc, ch)).collect();
match runs.last_mut() {
Some(r) if r.style == c.style => {
r.glyphs.end = glyphs.len();
r.text.push_str(&chars);
}
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars }),
}
x += c.adv;
}
b.glyphs.truncate(before_g);
Label { text: shown(text), ascent, style, glyphs, runs, x: 0.0, width: x }
});
let mut pl = ParaLayout {
rp,
styles: b.styles,
glyphs: b.glyphs,
clusters: b.clusters,
shown: b.shown,
lines: Vec::new(),
label,
height: 0.0,
text_len: p.text.len(),
has_page_fields,
notes,
issues: env.proofing.map(|o| proof_issues(p, &runs, &o)).unwrap_or_default(),
drop_cap,
hyph_after: Vec::new(),
bidi_levels: b.levels,
drop_cap_w,
maths,
rubies,
displays,
left_out: Vec::new(),
enclosures,
};
let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens;
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs);
pl.left_out = left;
for k in pl.hyph_after.clone() {
if let Some(c) = pl.clusters.get_mut(k as usize) {
c.break_after = true;
}
}
break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l));
pl
}
/// Next tab stop after `x` (column coordinates): explicit stops, the hanging-indent implicit stop,
/// then default stops.
fn next_tab(x: f32, tabs: &[TabStop], default_tab: f32, hanging_at: Option<f32>) -> TabStop {
let mut best: Option<TabStop> = tabs.iter().copied().filter(|t| t.pos > x + 0.01).min_by(|a, b| a.pos.total_cmp(&b.pos));
if let Some(h) = hanging_at
&& h > x + 0.01
&& best.is_none_or(|t| h < t.pos)
{
best = Some(TabStop { pos: h, align: TabAlign::Left, leader: TabLeader::None });
}
best.unwrap_or_else(|| {
let d = if default_tab > 1.0 { default_tab } else { 36.0 };
// Left of the margin Word's arithmetic truncates toward zero: the next default stop is
// the first multiple of the interval *at or after* `x`, so a tab that already sits on
// one (a second tab in a row, or a line starting at -72 pt) stays put. Measured on Word
// 2016+: a line starting at -72 pt with a leading tab stays at -72, at -60 goes to -36;
// "a\t\tX" at -60 puts X at -36, not 0. Letterheads with negative indents rely on it.
let n = if x < -0.01 { ((x - 0.01) / d).ceil() } else { ((x + 0.01) / d).floor() + 1.0 };
TabStop { pos: (n * d).min(1e6), align: TabAlign::Left, leader: TabLeader::None }
})
}
fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Option<&Level>) {
let suffix = level.map(|l| l.suffix);
let rp = pl.rp.clone();
let width = env.width.max(12.0);
let base_right = (width - rp.indent_right).max(1.0);
let default_tab = env.doc.settings.default_tab;
let first_left = rp.indent_left + rp.indent_first;
// Word 2013+ (compatibility mode 15) fits more text on a justified line by shrinking its
// spaces, up to MAX_SPACE_SHRINK of their width; never on lines with tabs.
let shrink_spaces = rp.align == Align::Justify && env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None };
// Hyphenation Options: the zone, and how many lines in a row may end with a hyphen.
let zone = Some(env.doc.settings.hyphenation_zone).filter(|z| z.is_finite() && *z >= 0.0).unwrap_or(HYPHENATION_ZONE).min(1584.0);
let hyph_limit = env.doc.settings.consecutive_hyphen_limit as usize;
let n = pl.clusters.len();
// A right-to-left paragraph is laid out from its start edge, as if mirrored: indents, tabs,
// the list label and alignment are measured from the right. Lines are mirrored back (and
// bidirectional text reordered) once broken; floating objects are mirrored in.
let rtl_para = rp.bidi;
// The document grid's line pitch, for paragraphs that snap to it.
let grid = Some(env.grid).filter(|g| g.line.is_some() && rp.snap_to_grid);
let grid = grid.as_ref();
let bidi = rtl_para || !pl.bidi_levels.is_empty();
let mirrored: Vec<Exclusion>;
let exclusions: &[Exclusion] = if rtl_para {
mirrored = env
.exclusions
.iter()
.map(|e| Exclusion {
left: width - e.right,
right: width - e.left,
contour: e.contour.as_ref().map(|c| Arc::new(c.mirrored(e.right - e.left))),
// Mirrored, the object's left side is the line's right.
side: match e.side {
WrapText::Left => WrapText::Right,
WrapText::Right => WrapText::Left,
s => s,
},
..e.clone()
})
.collect();
&mirrored
} else {
env.exclusions
};
let mut first_x0: Option<f32> = None;
let mut hcache: Vec<(u32, u32, f32)> = Vec::new();
let mut lines: Vec<Line> = Vec::new();
let mut i = 0usize;
let mut top = 0.0f32;
let mut first = true;
let est_h = pl
.styles
.get(mark_style as usize)
.map(|st| (st.ascent + st.descent) * if let LineSpacing::Multiple(m) = rp.line_spacing { m } else { 1.0 })
.unwrap_or(14.0)
.max(1.0);
// The rest of the current row: spans on the far side of floating objects, left to right.
let mut row_rest: VecDeque<(f32, f32)> = VecDeque::new();
let mut row_first = 0usize;
// Spans skipped in a row because the next word didn't fit (see `skip`).
let mut skipped = 0;
loop {
// A line already sits on this row (a span before this one may have been skipped).
let beside = !row_rest.is_empty() && lines.len() > row_first;
// The span is narrowed by a floating object.
let mut narrowed = !row_rest.is_empty();
let row_lo = if first { first_left } else { rp.indent_left };
let (left, right_edge) = if let Some(span) = row_rest.pop_front() {
span
} else {
// Flow around floating objects: the spans beside them, or below them.
let mut guard = 0;
loop {
let mut lo = if first { first_left } else { rp.indent_left };
if let Some((_, dl, dw)) = pl.drop_cap
&& lines.len() < dl as usize
{
lo = first_left.max(rp.indent_left) + dw;
}
match row_spans(exclusions, top, est_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
Ok((Some((a, b)), rest)) => {
narrowed = !rest.is_empty() || a > lo || b < base_right;
row_rest = rest;
row_first = lines.len();
break (a, b.max(a + 12.0));
}
Err(below) if below > top && guard < 50 => {
top = below;
guard += 1;
}
Ok((None, _)) | Err(_) => {
row_first = lines.len();
break (lo, base_right.max(lo + 12.0));
}
}
}
};
let mut x = left;
// Label on the first line.
if first && let Some(lab) = pl.label.as_mut() {
// The number is aligned at the first-line indent: its left edge, centre or right edge.
lab.x = match level.map(|l| l.align) {
Some(Align::Center) => left - lab.width / 2.0,
Some(Align::Right) => left - lab.width,
_ => left,
};
let end = lab.x + lab.width;
x = match suffix.unwrap_or(LevelSuffix::Tab) {
LevelSuffix::Tab => {
let t = next_tab(end, &rp.tabs, default_tab, hanging_at).pos;
// The level's own tab stop after the number, when it comes first.
match level.and_then(|l| l.tab).filter(|p| p.is_finite() && *p > end + 0.01) {
Some(p) if p < t => p,
_ => t,
}
}
LevelSuffix::Space => end + pl.styles.get(lab.style as usize).map(|s| s.size * 0.25).unwrap_or(3.0),
LevelSuffix::Nothing => end,
};
}
let line_start_x = x;
let c0 = i;
// The first word doesn't fit a span narrowed by a floating object: it moves on whole.
let mut skip = false;
let mut xs: Vec<f32> = Vec::new();
let mut leaders = Vec::new();
let mut last_break: Option<usize> = None; // cluster index after which we may break
let mut last_plain: Option<(usize, f32)> = None; // last non-hyphen break and the x after it
// After `hyph_limit` hyphenated lines in a row this one may not end with a hyphen.
let hyph_ok = hyph_limit == 0 || lines.iter().rev().take_while(|l| l.hyphen.is_some()).count() < hyph_limit;
let mut end = LineEnd::Para;
let mut j = i;
let mut pending_tab: Option<(usize, TabStop, f32)> = None; // tab cluster, stop, x where tab started
let mut space_w = 0.0f32; // width of the spaces so far on this line
let mut has_tab = false;
while j < n {
let Some(c) = pl.clusters.get(j).cloned() else { break };
match c.kind {
ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak => {
resolve_tab(pl, &mut pending_tab, &mut xs, c0, x, &mut x);
xs.push(x);
end = match c.kind {
ClKind::LineBreak => LineEnd::LineBreak,
ClKind::PageBreak => LineEnd::PageBreak,
_ => LineEnd::ColumnBreak,
};
j += 1;
break;
}
ClKind::Tab => {
has_tab = true;
resolve_tab(pl, &mut pending_tab, &mut xs, c0, x, &mut x);
let stop = next_tab(x, &rp.tabs, default_tab, hanging_at);
xs.push(x);
if stop.pos > right_edge + 0.01 && j > c0 && stop.align == TabAlign::Left {
// Tab past the right indent wraps (Word moves it to the next line).
if let Some(c) = pl.clusters.get_mut(j) {
c.adv = 0.0;
}
x = right_edge.max(x);
last_break = Some(j);
j += 1;
continue;
}
match stop.align {
TabAlign::Left | TabAlign::Bar | TabAlign::Clear => {
let w = (stop.pos - x).max(0.0);
if let Some(c) = pl.clusters.get_mut(j) {
c.adv = w;
}
if stop.leader != TabLeader::None {
leaders.push((j, stop.leader));
}
x += w;
}
_ => {
pending_tab = Some((j, stop, x));
if stop.leader != TabLeader::None {
leaders.push((j, stop.leader));
}
}
}
last_break = Some(j);
j += 1;
continue;
}
_ => {}
}
// A display equation sits on a line of its own.
let is_display = pl.displays.iter().any(|(ci, _)| *ci == j);
if is_display && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) {
end = LineEnd::Wrap;
break;
}
// Text after a right/centre tab grows leftwards into the tab's space first.
let absorbs = pending_tab.is_some_and(|(tj, stop, _)| {
let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0);
match stop.align {
TabAlign::Right | TabAlign::Decimal => room >= c.adv,
TabAlign::Center => room >= c.adv / 2.0 && x + c.adv / 2.0 <= right_edge + 0.01,
_ => false,
}
});
let shrink = if shrink_spaces && !has_tab { space_w * MAX_SPACE_SHRINK } else { 0.0 };
let fits = absorbs || x + c.adv <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
// A line's first item stays on it even when it doesn't fit; markers before it
// (a content control's start, a bookmark) take no room and don't count (Word).
if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) {
// Wrap: back up to the last break opportunity on this line.
end = LineEnd::Wrap;
// Hyphenate only when the plain break leaves more than the hyphenation zone empty.
if let (Some(bk), Some((pj, px))) = (last_break, last_plain)
&& pj >= c0
&& pj < bk
&& pl.hyph_after.binary_search(&(bk as u32)).is_ok()
&& right_edge - px <= zone
{
last_break = Some(pj);
}
if let Some(bk) = last_break.filter(|bk| *bk >= c0) {
xs.truncate(bk + 1 - c0);
j = bk + 1;
// Recompute x for the kept clusters.
x = xs.last().copied().unwrap_or(line_start_x) + pl.clusters.get(bk).map(|c| c.adv).unwrap_or(0.0);
} else {
// No opportunity: break here (a long word), unless the span is beside a
// floating object and the word fits the full line; then it tries the row's
// next span, or the next row. The word runs to the next break that isn't a
// hyphenation point.
let word: f32 = (c0..n)
.filter_map(|k| pl.clusters.get(k).map(|c| (k, c)))
.scan(false, |done, (k, c)| {
let end = c.break_after && pl.hyph_after.binary_search(&(k as u32)).is_err();
(!std::mem::replace(done, end)).then_some(c)
})
.filter(|c| c.kind != ClKind::Space)
.map(|c| c.adv)
.sum();
let full = base_right - row_lo;
skip = narrowed && skipped < 50 && word <= full + 0.01;
}
break;
}
xs.push(x);
x += c.adv;
if c.kind == ClKind::Space {
space_w += c.adv;
}
// Decimal/center/right tab: shift pending text as it grows.
if let Some((tj, stop, tx)) = pending_tab {
let seg_w = x - tx;
let shift = match stop.align {
TabAlign::Right => stop.pos - tx - seg_w,
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
TabAlign::Decimal => {
// Width up to the first '.' in the segment.
let mut w = 0.0;
let mut found = false;
for k in tj + 1..=j {
let Some(cc) = pl.clusters.get(k) else { break };
let s = pl_text_at(cc);
if found {
break;
}
if s {
found = true;
break;
}
w += cc.adv;
}
stop.pos - tx - if found { w } else { seg_w }
}
_ => 0.0,
};
let shift = shift.max(0.0);
let old = pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0);
if (shift - old).abs() > 0.001 {
let delta = shift - old;
if let Some(c) = pl.clusters.get_mut(tj) {
c.adv = shift;
}
let from = tj + 1 - c0;
for v in xs.iter_mut().skip(from) {
*v += delta;
}
x += delta;
}
}
if is_display {
// Markers right after it stay on its line; anything else starts a new one.
j += 1;
while j < n && pl.clusters.get(j).is_some_and(|c| c.kind == ClKind::Marker) {
xs.push(x);
j += 1;
}
end = if j >= n { LineEnd::Para } else { LineEnd::Wrap };
break;
}
if c.break_after {
let hyph = pl.hyph_after.binary_search(&(j as u32)).is_ok();
if !hyph {
last_break = Some(j);
last_plain = Some((j, x));
} else if hyph_ok && x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + shrink + 0.01 {
last_break = Some(j);
}
}
j += 1;
}
if skip {
skipped += 1;
if row_rest.is_empty() {
top = if lines.len() > row_first {
top + even_row(lines.get_mut(row_first..).unwrap_or_default())
} else {
// Nothing on this row: go below the first object that ends, as when no span is wide enough.
exclusions
.iter()
.filter(|e| e.bottom > top && e.top < top + est_h && e.right > row_lo && e.left < base_right)
.map(|e| e.bottom)
.reduce(f32::min)
.unwrap_or(top + est_h)
};
}
continue;
}
if end == LineEnd::Para || end == LineEnd::Wrap {
let xc = x;
resolve_tab(pl, &mut pending_tab, &mut xs, c0, xc, &mut x);
}
if j >= n && end != LineEnd::Wrap && !matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) {
end = LineEnd::Para;
}
let c1 = j.max(c0);
// Ensure progress.
let (c1, j) = if c1 == c0 && c0 < n { (c0 + 1, c0 + 1) } else { (c1, j) };
while xs.len() < c1 - c0 {
let last = xs.last().copied().unwrap_or(line_start_x);
let adv = pl.clusters.get(c0 + xs.len().saturating_sub(1)).map(|c| c.adv).unwrap_or(0.0);
xs.push(if xs.is_empty() { line_start_x } else { last + adv });
}
xs.truncate(c1 - c0);
// A line wrapped at a hyphenation point shows a hyphen after its last cluster.
let hyphen = (end == LineEnd::Wrap && c1 > c0 && pl.hyph_after.binary_search(&((c1 - 1) as u32)).is_ok())
.then(|| pl.clusters.get(c1 - 1).map(|c| hyphen_glyph(pl, c.style, &mut hcache)))
.flatten();
let end_x = match (xs.last(), c1.checked_sub(1).and_then(|k| pl.clusters.get(k))) {
(Some(lx), Some(c)) => lx + c.adv + hyphen.map_or(0.0, |h| h.2),
_ => line_start_x,
};
xs.push(end_x);
pending_tab = None;
let _ = pending_tab;
// Vertical metrics.
let (mut asc, mut desc) = (0.0f32, 0.0f32);
// The tallest picture on the line (it sits on the baseline).
let mut obj_asc = 0.0f32;
let mut any = false;
let dropped = pl.drop_cap.map_or(0, |d| d.0);
for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() {
// Spaces and tabs never make a line taller, whatever their size: Word sizes a line by
// its other characters (a line of only them takes the paragraph mark's), measured in
// Word for Windows with 9 pt text and 11–20 pt tabs, spaces and ideographic spaces.
if matches!(c.kind, ClKind::Marker | ClKind::Space | ClKind::Tab) || c0 + k < dropped {
continue;
}
if let Some(st) = pl.styles.get(c.style as usize) {
// A picture sits on the baseline of its run: the line keeps that font's ascent
// and descent, and grows above the baseline to fit a taller picture.
let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h > 0.0 {
if pl.maths.iter().any(|(k, _)| c.kind == ClKind::Object(*k)) {
// An equation is text: its ascent and descent count like the text's.
(c.obj_h, c.obj_d)
} else {
obj_asc = obj_asc.max(c.obj_h);
(st.ascent, st.descent)
}
} else {
// `obj_h` is set on text clusters only under ruby text, which the line grows to fit.
((st.ascent + st.shift.max(0.0)).max(c.obj_h + st.shift), st.descent + (-st.shift).max(0.0))
};
asc = asc.max(a);
desc = desc.max(d);
any = true;
}
}
if (!any || (asc == 0.0 && desc == 0.0))
&& let Some(st) = pl.styles.get(mark_style as usize)
{
asc = st.ascent;
desc = st.descent;
}
let text_natural = asc + desc;
let text_pictures = if obj_asc > asc { obj_asc + desc } else { 0.0 };
let mut label_extra = 0.0;
if first && let Some(lab) = &pl.label {
if !matches!(rp.line_spacing, LineSpacing::Exactly(_)) {
// Automatic/minimum spacing keeps text descent. Only the label's ascent
// overflow adds room, unscaled by the automatic spacing multiplier.
label_extra = (lab.ascent - asc).max(0.0);
asc = asc.max(lab.ascent);
} else {
for st in lab.runs.iter().filter_map(|r| pl.styles.get(r.style as usize)) {
asc = asc.max(st.ascent);
desc = desc.max(st.descent);
}
}
}
// Keep the picture floor independent of list-label metrics and line-spacing multipliers.
// shortcut: Word leaves less than a full descent below a line of only pictures (0.5pt to
// 1.9pt in docxide-pdf's header fixtures case121/case123, where the descent is 3.3pt), and
// no rule tried so far (none, only the text's descent) matched both; it needs Word probes.
let natural = asc + desc;
let pictures = if obj_asc > asc { obj_asc + desc } else { 0.0 };
// Document grid: the line takes whole line pitches, its text centred in them.
let (height, centred) = match (rp.line_spacing, grid) {
(LineSpacing::Multiple(m), Some(g)) => ((g.snap_height(natural) * m).max(g.snap_height(pictures)), true),
(LineSpacing::AtLeast(v), Some(g)) => (g.snap_height(natural.max(pictures)).max(v), true),
(LineSpacing::Multiple(m), None) => ((text_natural * m + label_extra).max(text_pictures), false),
(LineSpacing::AtLeast(v), None) => (natural.max(text_pictures).max(v), false),
(LineSpacing::Exactly(v), _) => (v, false),
};
let baseline = if centred {
let up = asc.max(obj_asc);
top + ((height - up - desc) / 2.0).max(0.0) + up
} else {
top + baseline_offset(rp.line_spacing, asc, height, desc).max(obj_asc.min(height - desc))
};
// Alignment / justification (trailing spaces hang).
let mut content_end = xs.last().copied().unwrap_or(line_start_x);
for k in (c0..c1).rev() {
match pl.clusters.get(k).map(|c| c.kind) {
Some(ClKind::Space) | Some(ClKind::Marker) | Some(ClKind::LineBreak) | Some(ClKind::PageBreak) | Some(ClKind::ColumnBreak) => {
content_end = xs.get(k - c0).copied().unwrap_or(content_end);
}
_ => break,
}
}
let slack = right_edge - content_end;
let last_tab = (c0..c1).rev().find(|k| pl.clusters.get(*k).is_some_and(|c| c.kind == ClKind::Tab));
let align_from = last_tab.map(|k| k + 1).unwrap_or(c0);
let display = pl.displays.iter().find(|(ci, _)| *ci >= c0 && *ci < c1).map(|d| d.1);
let shift = match (display, rp.align) {
(Some(MathJc::Left), _) => 0.0,
(Some(MathJc::Right), _) => slack,
(Some(_), _) => slack / 2.0,
(None, Align::Center) => slack / 2.0,
(None, Align::Right) => slack,
_ => 0.0,
};
if shift > 0.0 && slack > 0.0 {
for v in xs.iter_mut().skip(align_from - c0) {
*v += shift;
}
// The list label belongs to the start of the line: it moves with the text (Word keeps
// the bullet attached when a list item is centred or aligned to the end). After a tab
// stop in the line the label stays, as the text before the tab does.
if first
&& last_tab.is_none()
&& let Some(lab) = pl.label.as_mut()
{
lab.x += shift;
}
}
let justify = display.is_none() && ((rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute);
// A line that only fits with shrunk spaces is shrunk, the last line of the paragraph too.
let shrink = display.is_none() && shrink_spaces && slack < 0.0 && last_tab.is_none();
if shrink {
shrink_line_spaces(pl, &mut xs, c0, c1, content_end, slack);
} else if justify && slack > 0.0 {
// Spaces inside the content (after the last tab).
let spaces: Vec<usize> = (align_from..c1)
.filter(|k| {
pl.clusters.get(*k).is_some_and(|c| c.kind == ClKind::Space) && xs.get(k - c0).copied().unwrap_or(f32::MAX) < content_end - 0.01
})
.collect();
if rp.align == Align::Distribute || spaces.is_empty() {
let cnt = (c1 - align_from).saturating_sub(1).max(1) as f32;
if rp.align == Align::Distribute {
let per = slack / cnt;
for (q, v) in xs.iter_mut().enumerate().skip(align_from - c0) {
let k = (q + c0 - align_from) as f32;
*v += per * k.min(cnt);
}
}
} else {
let per = slack / spaces.len() as f32;
let mut add = 0.0;
let mut si = 0;
for k in align_from..c1 {
if let Some(v) = xs.get_mut(k - c0) {
*v += add;
}
if spaces.get(si) == Some(&k) {
add += per;
si += 1;
}
}
if let Some(v) = xs.last_mut() {
*v += add;
}
}
}
let start = pl.clusters.get(c0).map(|c| c.start).unwrap_or(pl.text_len);
let stop = if c1 > c0 { pl.clusters.get(c1 - 1).map(|c| c.end).unwrap_or(pl.text_len) } else { start };
if first_x0.is_none() {
first_x0 = xs.first().copied();
}
let mut line = Line {
top,
height,
baseline,
c0,
c1,
xs,
leaders,
end,
start,
stop,
left,
right: right_edge,
hyphen,
beside,
vis: Vec::new(),
rtl: rtl_para,
};
if bidi {
reorder_line(pl, &mut line, width);
}
lines.push(line);
first = false;
skipped = 0;
i = j;
// Text that wrapped continues on the row's far side; anything else ends the row.
if end != LineEnd::Wrap || i >= n {
row_rest.clear();
}
if row_rest.is_empty() {
top += even_row(lines.get_mut(row_first..).unwrap_or_default());
}
if i >= n {
// A paragraph ending with a line break gets an empty last line.
if matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) {
let (asc, desc) = pl.styles.get(mark_style as usize).map(|s| (s.ascent, s.descent)).unwrap_or((10.0, 3.0));
let natural = asc + desc;
let h = match (rp.line_spacing, grid) {
(LineSpacing::Multiple(m), Some(g)) => g.snap_height(natural) * m,
(LineSpacing::AtLeast(v), Some(g)) => g.snap_height(natural).max(v),
(LineSpacing::Multiple(m), None) => natural * m,
(LineSpacing::AtLeast(v), None) => natural.max(v),
(LineSpacing::Exactly(v), _) => v,
};
let x0 = rp.indent_left;
let (x0, left, right) = if rtl_para { (width - x0, width - base_right, width - x0) } else { (x0, x0, base_right) };
lines.push(Line {
top,
height: h,
baseline: top + if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) },
c0: n,
c1: n,
xs: vec![x0],
leaders: Vec::new(),
end: LineEnd::Para,
start: pl.text_len,
stop: pl.text_len,
left,
right,
hyphen: None,
beside: false,
vis: Vec::new(),
rtl: rtl_para,
});
top += h;
}
break;
}
if lines.len() > 200_000 {
break;
}
}
// Hang the drop cap from the first line's caps down to line N's baseline, left of the text.
if let Some((nc, dl, dw)) = pl.drop_cap
&& let (Some(l0), Some(ln)) = (lines.first(), lines.get((dl as usize).min(lines.len()).saturating_sub(1)))
{
let short = lines.len() < dl as usize;
let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline };
let dx = dw.min(first_x0.unwrap_or(0.0) - first_left.max(rp.indent_left));
// Toward the start edge: left, or right (and from the letter's right edge) when mirrored.
let dx = if rtl_para { pl.drop_cap_w - dx } else { dx };
if short {
top = top.max(l0.top + l0.height * dl as f32);
}
for c in pl.clusters.get(..nc).unwrap_or(&[]).to_vec() {
for g in pl.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dy -= dy;
g.dx -= dx;
}
}
}
if rtl_para && let Some(lab) = pl.label.as_mut() {
lab.x = width - lab.x - lab.width;
}
pl.height = top;
pl.lines = lines;
}
/// Make a row's lines as tall as its tallest, and return that height.
fn even_row(row: &mut [Line]) -> f32 {
let h = row.iter().map(|l| l.height).fold(0.0f32, f32::max);
for l in row {
l.baseline += h - l.height;
l.height = h;
}
h
}
/// Finish a broken line of a bidirectional paragraph: resolve each cluster's level for the line
/// (UAX #9 rule L1), order the clusters visually (rule L2) and give each its box. `line.xs`
/// arrive in logical order measured from the paragraph's start edge (mirrored for a
/// right-to-left paragraph); afterwards `line.vis` holds the boxes and `xs` their left edges.
fn reorder_line(pl: &ParaLayout, line: &mut Line, width: f32) {
let rtl_para = line.rtl;
if rtl_para {
let (l, r) = (line.left, line.right);
line.left = width - r;
line.right = width - l;
}
let (c0, c1) = (line.c0, line.c1);
let n = c1.saturating_sub(c0);
if n == 0 {
if rtl_para {
line.xs = line.xs.iter().map(|x| width - x).collect();
}
return;
}
let para_level = u8::from(rtl_para);
let mut levels: Vec<u8> = (c0..c1).map(|k| pl.clusters.get(k).and_then(|c| pl.bidi_levels.get(c.start)).copied().unwrap_or(para_level)).collect();
// L1: tabs and breaks, and the whitespace before them or at the end of the line, take the
// paragraph level (so trailing spaces stay at the line's end edge).
let mut trailing = true;
for (i, k) in (c0..c1).enumerate().rev() {
let kind = pl.clusters.get(k).map(|c| c.kind);
let reset = match kind {
Some(ClKind::Tab | ClKind::LineBreak) => {
trailing = true;
true
}
Some(ClKind::Space | ClKind::PageBreak | ClKind::ColumnBreak | ClKind::Marker) => trailing,
_ => {
trailing = false;
false
}
};
if reset && let Some(v) = levels.get_mut(i) {
*v = para_level;
}
}
if !rtl_para && levels.iter().all(|l| *l == 0) {
return; // A left-to-right line of a paragraph with right-to-left text elsewhere.
}
let order = visual_order(&levels);
let hyph = line.hyphen.map_or(0.0, |h| h.2);
let mut w: Vec<f32> = (0..n).map(|i| line.xs.get(i + 1).zip(line.xs.get(i)).map_or(0.0, |(b, a)| b - a)).collect();
if let Some(last) = w.last_mut() {
*last -= hyph;
}
let total: f32 = w.iter().sum();
let x0 = line.xs.first().copied().unwrap_or(0.0);
let mut x = if rtl_para { width - x0 - total } else { x0 };
let mut vis = vec![VisCl { x: 0.0, w: 0.0, level: para_level }; n];
for &i in &order {
if let (Some(v), Some(wi), Some(l)) = (vis.get_mut(i), w.get(i), levels.get(i)) {
*v = VisCl { x, w: *wi, level: *l };
x += *wi;
}
}
line.vis = vis;
let end = line.end_x();
line.xs = line.vis.iter().map(|v| v.x).chain(std::iter::once(end)).collect();
}
/// Narrowest span text flows into beside a floating object, points. Word puts an empty
/// paragraph beside a floating table in a 21.3pt gap but not in an 18.6pt one.
const MIN_SPAN: f32 = 20.0;
/// How far below a line's top its baseline sits. Multiple spacing at or above single keeps the
/// text at the top of the line and puts the extra leading below the baseline; below single it
/// shrinks the whole box, ascent included (Word's output for docxide-pdf's fixtures: 1.15-spaced
/// Times New Roman 12pt sits one ascent below the line top, not 1.8pt lower). Exact and at-least
/// spacing keep the descent below the baseline and the rest above it.
fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f32 {
match spacing {
LineSpacing::Multiple(m) => asc * m.min(1.0),
LineSpacing::AtLeast(_) | LineSpacing::Exactly(_) => height - desc,
}
}
/// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around
/// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none
/// here, try again at `y` (below what's in the way).
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.bottom > top && e.top < top + h && e.right > lo && e.left < hi).collect();
if let Some(below) = here.iter().filter(|e| e.top_bottom).map(|e| e.bottom).reduce(f32::max) {
return Err(below);
}
// What each object takes of the row: its whole area, or (Tight, Through) the stretches its
// outline takes, as (left, right, bottom of the object's area).
let mut taken: Vec<(f32, f32, f32)> = Vec::new();
for e in &here {
let first = taken.len();
match &e.contour {
None => taken.push((e.left, e.right, e.bottom)),
Some(c) => taken.extend(c.occupied(top - e.top, top + h - e.top).into_iter().map(|(a, b)| (a + e.left, b + e.left, e.bottom))),
}
// Text on one side only: the object's other side of the row is taken too.
let Some(mine) = taken.get(first..).filter(|t| !t.is_empty() && !e.side.is_both()) else { continue };
let left = mine.iter().map(|t| t.0).fold(f32::INFINITY, f32::min);
let right = mine.iter().map(|t| t.1).fold(f32::NEG_INFINITY, f32::max);
let text_left = match e.side {
WrapText::Left => true,
WrapText::Right => false,
// The side with more room on this line (left on a tie).
_ => left - lo >= hi - right,
};
taken.truncate(first);
taken.push(if text_left { (left, hi.max(right), e.bottom) } else { (lo.min(left), right, e.bottom) });
}
taken.retain(|(l, r, _)| *r > lo && *l < hi);
if taken.is_empty() {
return Ok(VecDeque::from([(lo, hi)]));
}
let mut spans = vec![(lo, hi)];
for &(left, right, _) in &taken {
spans = spans
.into_iter()
.flat_map(|(a, b)| if right <= a || left >= b { vec![(a, b)] } else { vec![(a, left.min(b)), (right.max(a), b)] })
.collect();
}
spans.retain(|(a, b)| b - a >= MIN_SPAN);
if spans.is_empty() {
// Nothing wide enough: the line goes below the first object that ends.
return Err(taken.iter().map(|t| t.2).reduce(f32::min).unwrap_or(top));
}
Ok(spans.into())
}
/// Word's default hyphenation zone (0.25"), points.
const HYPHENATION_ZONE: f32 = wordcraft_doc::DEFAULT_HYPHENATION_ZONE;
/// How much of their width the spaces of a justified line may give up to fit more text (Word
/// 2013+, compatibility mode 15).
const MAX_SPACE_SHRINK: f32 = 0.2;
/// Take `-slack` (an overflow) out of the line's inner spaces, in proportion to their widths and
/// never more than [`MAX_SPACE_SHRINK`] of them. Trailing spaces hang and are left alone.
fn shrink_line_spaces(pl: &ParaLayout, xs: &mut [f32], c0: usize, c1: usize, content_end: f32, slack: f32) {
let spaces: Vec<(usize, f32)> = (c0..c1)
.filter_map(|k| {
let c = pl.clusters.get(k).filter(|c| c.kind == ClKind::Space)?;
(xs.get(k - c0).copied().unwrap_or(f32::MAX) < content_end - 0.01).then_some((k, c.adv))
})
.collect();
let total: f32 = spaces.iter().map(|s| s.1).sum();
if total <= 0.0 || !slack.is_finite() {
return;
}
let ratio = (-slack / total).clamp(0.0, MAX_SPACE_SHRINK);
let mut take = 0.0;
let mut next = spaces.iter().peekable();
for k in c0..c1 {
if let Some(v) = xs.get_mut(k - c0) {
*v -= take;
}
if let Some((_, adv)) = next.next_if(|s| s.0 == k) {
take += adv * ratio;
}
}
if let Some(v) = xs.last_mut() {
*v -= take;
}
}
/// The hyphen glyph and advance in a style (cached per paragraph).
fn hyphen_glyph(pl: &ParaLayout, style: u32, cache: &mut Vec<(u32, u32, f32)>) -> (u32, u32, f32) {
if let Some(h) = cache.iter().find(|h| h.0 == style) {
return *h;
}
let h = pl
.styles
.get(style as usize)
.and_then(|st| {
let g = wordcraft_fonts::shape(&st.face, "-", &[], |c| c).into_iter().next()?;
let k = st.size / st.face.upem.max(1.0) as f32 * st.rc.scale / 100.0;
Some((style, g.gid, g.x_advance as f32 * k))
})
.unwrap_or((style, 0, 0.0));
cache.push(h);
h
}
/// Clusters after which a line may end with a hyphen: soft hyphens always, and dictionary or
/// pattern hyphenation points of each word, in the language of its run ([`ProofRun`]), when
/// automatic hyphenation is on (words in capitals only with `caps`). Both are found in `text`, the paragraph as laid out (without what
/// the layout leaves out, [`left_out`]), and `to_para` maps the end of a char there to the
/// paragraph's text.
fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl: &ParaLayout, auto: bool, caps: bool, runs: &[ProofRun]) -> Vec<u32> {
let mut bytes: Vec<usize> = text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect();
if auto {
let lim = wordcraft_proof::hyphen::Limits::default();
let mut start: Option<usize> = None;
for (i, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
let word_char = c.is_alphabetic() || c == '\'' || c == '\u{2019}';
match (start, word_char) {
(None, true) => start = Some(i),
(Some(a), false) => {
// The patterns are for Latin-script languages: never hyphenate Persian/Arabic words.
// Words in capitals (`NASA`, `README`) only when the document allows it.
let all_caps = |w: &str| w.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase);
if let Some(w) = text.get(a..i)
&& w.chars().count() >= lim.min_word
&& !w.chars().any(wordcraft_fonts::is_rtl)
&& (caps || !all_caps(w))
{
// The word's language: its first letter's run, found by its place in `p.text`.
let first = w.chars().next().map_or(1, char::len_utf8);
let lang = to_para(a + first).map(|e| lang_at(runs, e.saturating_sub(first))).unwrap_or_default();
let offs: Vec<usize> = w.char_indices().map(|(o, _)| o).collect();
for pt in wordcraft_proof::hyphen::hyphen_points_in(w, &lim, lang) {
if let Some(o) = offs.get(pt) {
bytes.push(a + o);
}
}
}
start = None;
}
_ => {}
}
}
}
let mut bytes: Vec<usize> = bytes.into_iter().filter_map(to_para).collect();
if bytes.is_empty() {
return Vec::new();
}
bytes.sort_unstable();
let mut out: Vec<u32> = pl
.clusters
.iter()
.enumerate()
.filter(|(_, c)| matches!(c.kind, ClKind::Text | ClKind::Marker) && bytes.binary_search(&c.end).is_ok())
.map(|(i, _)| i as u32)
.collect();
out.dedup();
out
}
fn pl_text_at(c: &Cluster) -> bool {
c.dot
}
fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, xs: &mut [f32], c0: usize, x: f32, xo: &mut f32) {
// Finish a centre/right/decimal tab: its text (possibly empty) ends at `x`.
let Some((tj, stop, tx)) = pending.take() else { return };
let seg = x - tx - pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0);
let want = match stop.align {
TabAlign::Right | TabAlign::Decimal => stop.pos - tx - seg,
TabAlign::Center => stop.pos - tx - seg / 2.0,
_ => return,
}
.max(0.0);
let old = pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0);
let delta = want - old;
if delta.abs() > 0.001 {
if let Some(c) = pl.clusters.get_mut(tj) {
c.adv = want;
}
for v in xs.iter_mut().skip(tj + 1 - c0) {
*v += delta;
}
*xo += delta;
}
}
impl ParaLayout {
/// The line the list label is drawn on: the first, unless the paragraph opens with a page
/// break, when Word draws it with the text on the next page.
pub fn label_line(&self) -> usize {
let opens_with_break = self.lines.len() > 1
&& self.lines.first().is_some_and(|l| {
l.end == LineEnd::PageBreak
&& self.clusters.get(l.c0..l.c1).is_some_and(|cs| cs.iter().all(|c| matches!(c.kind, ClKind::PageBreak | ClKind::Marker)))
});
usize::from(opens_with_break)
}
/// x of byte offset `off` within line `li` (clamped to the line).
pub fn x_of(&self, li: usize, off: usize) -> Option<f32> {
let l = self.lines.get(li)?;
if !l.vis.is_empty() {
return self.x_of_bidi(l, off);
}
for k in l.c0..l.c1 {
let c = self.clusters.get(k)?;
if off <= c.start {
return l.xs.get(k - l.c0).copied();
}
if off < c.end {
return l.xs.get(k - l.c0).copied();
}
}
l.xs.last().copied()
}
/// The line holding offset `off` (a position at a wrap point belongs to the next line).
pub fn line_of(&self, off: usize) -> usize {
for (i, l) in self.lines.iter().enumerate() {
let next_start = self.lines.get(i + 1).map(|n| n.start);
match next_start {
Some(ns) if off < ns => return i,
Some(_) => {}
None => return i,
}
if matches!(l.end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) && off < l.stop {
return i;
}
}
self.lines.len().saturating_sub(1)
}
/// Caret x on a bidirectional line: the leading edge of the cluster after `off` (the right
/// edge of right-to-left text), except where `off` leaves an embedded run (the cluster before
/// it has the higher level), which keeps the caret at that run's trailing edge, so each
/// visual boundary of the common mixed cases has its own offset.
fn x_of_bidi(&self, l: &Line, off: usize) -> Option<f32> {
let k = (l.c0..l.c1).find(|&k| self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start));
let Some(k) = k else { return Some(l.end_x()) };
let c = self.clusters.get(k)?;
let here = l.vis.get(k - l.c0)?;
if off > c.start || k == l.c0 {
return Some(here.leading());
}
let prev = l.vis.get(k - 1 - l.c0)?;
Some(if prev.level > here.level { prev.trailing() } else { here.leading() })
}
/// Visual extents, left to right, of the text between byte offsets `from..to` on line `li`
/// (one span unless bidirectional text splits the range).
pub fn x_spans(&self, li: usize, from: usize, to: usize) -> Vec<(f32, f32)> {
let Some(l) = self.lines.get(li) else { return Vec::new() };
if l.vis.is_empty() {
return match (self.x_of(li, from), self.x_of(li, to)) {
(Some(a), Some(b)) => vec![(a, b)],
_ => Vec::new(),
};
}
let ks: Vec<usize> = (l.c0..l.c1).filter(|&k| self.clusters.get(k).is_some_and(|c| c.start < to && c.end > from)).collect();
match (ks.first(), ks.last()) {
(Some(&a), Some(&b)) => l.spans(a, b + 1),
_ => Vec::new(),
}
}
/// Caret offsets a click or arrow key can reach on line `li`: each cluster start and the line
/// end, except after a line-ending break or past a wrapped line's trailing space.
pub fn line_offsets(&self, li: usize) -> Vec<usize> {
let Some(l) = self.lines.get(li) else { return Vec::new() };
let mut v: Vec<usize> = (l.c0..l.c1).filter_map(|k| self.clusters.get(k).map(|c| c.start)).collect();
let last_break =
l.c1 > l.c0 && self.clusters.get(l.c1 - 1).is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak));
let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some();
if !last_break && !wrapped || l.c1 == l.c0 {
v.push(l.stop);
}
v
}
/// Byte offset closest to x on line `li`.
pub fn off_at_x(&self, li: usize, x: f32) -> usize {
let Some(l) = self.lines.get(li) else { return self.text_len };
if !l.vis.is_empty() {
let mut best = (l.start, f32::MAX);
for off in self.line_offsets(li) {
let d = self.x_of(li, off).map_or(f32::MAX, |cx| (cx - x).abs());
if d < best.1 {
best = (off, d);
}
}
return best.0;
}
let mut best = l.start;
let mut bestd = f32::MAX;
for k in l.c0..=l.c1 {
let Some(cx) = l.xs.get(k - l.c0) else { break };
let off = if k < l.c1 { self.clusters.get(k).map(|c| c.start).unwrap_or(l.stop) } else { l.stop };
// Don't place the caret after a line-ending break or wrap-trailing space on a wrapped line.
if k == l.c1 && k > l.c0 {
let lastc = self.clusters.get(k - 1);
if lastc.is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak)) {
continue;
}
if l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some() {
continue;
}
}
let d = (cx - x).abs();
if d < bestd {
bestd = d;
best = off;
}
}
best
}
/// Line ascent/descent of the paragraph mark (for empty paragraphs and caret height).
pub fn caret_metrics(&self, li: usize) -> (f32, f32) {
let Some(l) = self.lines.get(li) else { return (10.0, 3.0) };
(l.baseline - l.top, l.top + l.height - l.baseline)
}
}
/// One run's proofing: its bytes in the paragraph, its language (`w:lang`, resolved through the
/// styles: the document default, paragraph and character styles, direct formatting) and whether
/// it is left unchecked ("Do not check spelling or grammar", hidden text, hyperlinks).
#[derive(Clone, Debug, PartialEq)]
pub struct ProofRun {
pub range: std::ops::Range<usize>,
pub lang: wordcraft_proof::ProofLang,
pub skip: bool,
}
impl ProofRun {
fn of(range: std::ops::Range<usize>, rc: &ResolvedChar) -> ProofRun {
ProofRun { range, lang: wordcraft_proof::ProofLang::from_tag(rc.lang.as_deref()), skip: rc.no_proof || rc.hidden || rc.link.is_some() }
}
}
/// The proofing runs of a paragraph, in order. `table` is the cell's table-style character
/// formatting ([`CellText::chr`]) for a paragraph in a table, as layout resolves it.
pub fn proof_runs(doc: &Document, p: &Paragraph, table: Option<&CharProps>) -> Vec<ProofRun> {
let style = p.props.style.as_deref();
p.run_ranges().map(|(r, c)| ProofRun::of(r, &doc.styles.resolve_char_in(style, table, c))).collect()
}
/// The language of the run holding byte `off` (English past the last run).
fn lang_at(runs: &[ProofRun], off: usize) -> wordcraft_proof::ProofLang {
let k = runs.partition_point(|r| r.range.end <= off);
runs.get(k).filter(|r| r.range.start <= off).map(|r| r.lang).unwrap_or_default()
}
/// Spelling and grammar issues in a paragraph, in order: each word in its run's language
/// ([`wordcraft_proof::check_text_with`]: what spelling skips, and grammar only with `grammar`),
/// and none touching a run left unchecked.
pub fn paragraph_issues(p: &Paragraph, runs: &[ProofRun], spelling: &wordcraft_proof::Options, grammar: bool) -> Vec<wordcraft_proof::Issue> {
if p.text.trim().is_empty() || p.text.len() > 100_000 {
return Vec::new();
}
let text = proof_text(p);
let spans: Vec<(std::ops::Range<usize>, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect();
let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end);
wordcraft_proof::check_text_with(&text, &spans, spelling, grammar).into_iter().filter(|i| !skip(i.start, i.end)).collect()
}
/// Spelling and grammar issues in a paragraph as squiggles: (start, end, is grammar).
fn proof_issues(p: &Paragraph, runs: &[ProofRun], opts: &crate::ProofOptions) -> Vec<(usize, usize, bool)> {
paragraph_issues(p, runs, &opts.spelling, opts.grammar)
.into_iter()
.map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar))
.collect()
}
/// Text for proofing with the same byte offsets: inline objects and tracked deletions become
/// U+0001 bytes (neither words nor spaces).
pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
let mut out = String::with_capacity(p.text.len());
for (i, c) in p.text.char_indices() {
let deleted = c == wordcraft_doc::para::OBJ || p.props_of_char(i).del.is_some();
if deleted {
for _ in 0..c.len_utf8() {
out.push('\u{1}');
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod label_spacing_tests {
use super::*;
// Inject measured metric pairs after shaping so these regressions do not depend on
// the fonts installed on the test host. Word for Mac 16.113.3, original Roboto probes.
fn measured_label_lines(spacing: LineSpacing, label_metrics: (f32, f32), label_ascent: f32, wrap: bool) -> Vec<Line> {
let doc = Document::new();
let fields = FieldCtx::default();
let level = Level { chr: CharProps { size: Some(24.0), ..Default::default() }, suffix: LevelSuffix::Nothing, ..Default::default() };
let env = ParaEnv {
doc: &doc,
fields: &fields,
width: 100.0,
label: Some(("•".into(), level.clone())),
show_hidden: false,
hide_deleted: false,
table: None,
proofing: None,
field_codes: false,
grid: Grid::default(),
exclusions: &[],
eq_number: 0,
numeral: crate::NumeralMode::default(),
read_line_spacing: None,
fit_objects: false,
};
let mut p = Paragraph::with_text(if wrap { "Alpha probe synthetic business text repeated" } else { "Alpha probe" }, Default::default());
p.props.line_spacing = Some(spacing);
let mut pl = layout_para(&p, &env);
let label = pl.label.as_mut().unwrap();
label.ascent = label_ascent;
let label_style = label.style;
for (i, st) in pl.styles.iter_mut().enumerate() {
(st.ascent, st.descent) = if i == label_style as usize { label_metrics } else { (11.1328125, 2.9296875) };
}
let mark_style = pl.clusters[0].style;
break_lines(&mut pl, &env, mark_style, Some(&level));
pl.lines
}
#[test]
fn large_label_overflow_is_not_multiplied() {
for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] {
let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false);
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
if multiple == 1.0 {
assert!((lines[0].height - lines[0].baseline - 2.9296875).abs() < 0.001, "label descent must not move the text baseline");
}
}
}
#[test]
fn small_label_descent_does_not_inflate_text_spacing() {
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false);
assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height);
}
#[test]
fn label_leading_and_earlier_font_ascent_are_retained() {
// Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text
// pitch is still Roboto 12 pt. A shorter final fallback run must not hide it.
for metrics in [(16.998047, 7.001953), (8.0, 3.0)] {
let lines = measured_label_lines(LineSpacing::Multiple(1.25), metrics, 18.0, false);
assert!((lines[0].height - 24.48).abs() <= 0.25, "{}", lines[0].height);
}
}
#[test]
fn minimum_spacing_keeps_text_descent_and_label_leading() {
for (metrics, ascent, expected) in
[((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)]
{
let lines = measured_label_lines(LineSpacing::AtLeast(12.0), metrics, ascent, false);
assert!((lines[0].height - expected).abs() <= 0.25, "{}", lines[0].height);
}
}
#[test]
fn labels_keep_fixed_and_minimum_spacing_rules() {
for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] {
let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false);
assert!((lines[0].height - expected).abs() < 0.001);
}
}
#[test]
fn label_overflow_only_affects_the_first_wrapped_line() {
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true);
assert!(lines.len() > 1);
assert!((lines[0].height - 28.80).abs() <= 0.25);
for line in &lines[1..] {
assert!((line.height - 17.52).abs() <= 0.25, "{}", line.height);
}
}
}