wordcraft/crates/layout/src/para.rs

1161 lines
47 KiB
Rust

//! Paragraph layout: resolve runs, shape them into clusters, break lines (first-fit, as word
//! processors do), place tabs, list labels and alignment.
use std::sync::Arc;
use wordcraft_doc::numbering::{Level, LevelSuffix};
use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, SOFT_HYPHEN};
use wordcraft_doc::props::{Align, CharProps, LineSpacing, TabAlign, TabLeader, TabStop};
use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
use wordcraft_doc::{Document, Paragraph};
use wordcraft_fonts::FaceRef;
use crate::fields::{FieldCtx, field_text};
/// 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: u16,
/// 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,
/// 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<(u16, u32, f32)>,
}
/// The list label drawn on the first line.
#[derive(Clone, Debug)]
pub struct Label {
pub text: String,
pub style: u16,
pub glyphs: Vec<Glyph>,
pub x: f32,
pub width: f32,
}
/// 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>,
}
/// 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,
/// Extra style applied to every run (table style conditional formatting), under direct formatting.
pub table_chr: Option<&'a CharProps>,
pub proofing: bool,
/// 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],
}
/// An area text wraps around.
#[derive(Clone, Copy, 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,
}
fn style_key(rc: &ResolvedChar) -> String {
format!("{}|{}|{}|{}|{}", rc.font, rc.size, rc.bold, rc.italic, rc.vert_align as u8)
}
struct Builder<'a> {
env: &'a ParaEnv<'a>,
styles: Vec<StyleRun>,
style_index: std::collections::HashMap<(String, u32, bool), u16>,
glyphs: Vec<Glyph>,
clusters: Vec<Cluster>,
}
impl<'a> Builder<'a> {
/// 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) -> u16 {
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
let face = face_override.unwrap_or(r.face);
let key = (
format!("{}|{:?}|{}|{}", style_key(rc), rc.color, rc.underline as u8, small),
face.id(),
rc.strike || rc.double_strike || rc.link.is_some(),
);
let key = (format!("{}|{:?}|{:?}|{:?}|{:?}|{}", key.0, rc.highlight, rc.shading, rc.ins, rc.del, rc.hidden), key.1, key.2);
if let Some(i) = self.style_index.get(&key) {
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);
let st = StyleRun {
face,
size,
synth_bold: r.synth_bold && face_override.is_none(),
synth_italic: r.synth_italic && face_override.is_none(),
rc: rc.clone(),
ascent: (a * k) as f32,
descent: (d * k) as f32,
shift: rc.baseline_shift(),
};
let i = self.styles.len().min(u16::MAX as usize) as u16;
self.styles.push(st);
self.style_index.insert(key, i);
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>) {
if text.is_empty() {
return;
}
// Split by font coverage (fallback faces) and small caps case.
let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face;
let mut seg_start = 0;
let mut cur: Option<(Option<FaceRef>, bool)> = None;
let mut segs: Vec<(usize, usize, Option<FaceRef>, bool)> = Vec::new();
for (i, c) in text.char_indices() {
let face = if primary.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN {
None
} else {
wordcraft_fonts::FontDb::global().fallback_for(c, primary.id()).map(|f| FaceRef::of(&f))
};
let small = rc.small_caps && !rc.caps && c.is_lowercase();
let k = (face, small);
match cur {
Some(p) if p.0.map(|f| f.id()) == k.0.map(|f| f.id()) && p.1 == k.1 => {}
Some(p) => {
segs.push((seg_start, i, p.0, p.1));
seg_start = i;
cur = Some(k);
}
None => cur = Some(k),
}
}
if let Some(p) = cur {
segs.push((seg_start, text.len(), p.0, p.1));
}
for (a, b, face, small) in segs {
let Some(sub) = text.get(a..b) else { continue };
let si = self.style(rc, face, small);
let Some(st) = self.styles.get(si as usize).cloned() else { continue };
let upper = rc.caps || small;
let shaped = wordcraft_fonts::shape(&st.face, sub, &[], |c| if upper { c.to_uppercase().next().unwrap_or(c) } else { c });
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.
let bounds: Vec<usize> = unicode_segmentation::UnicodeSegmentation::grapheme_indices(sub, true).map(|(i, _)| i).collect();
let mut gi = 0usize;
for (bi, &gs) in bounds.iter().enumerate() {
let ge = bounds.get(bi + 1).copied().unwrap_or(sub.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 = sub.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 };
self.clusters.push(Cluster {
start: base + a + gs,
end: base + a + ge,
adv,
kind,
style: si,
g0,
g1: if kind == ClKind::Marker { g0 } else { g1 },
break_after: false,
obj_h: 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(text, start, rc, None);
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,
dot: false,
});
return;
};
// Merge into one cluster: rebase glyph dx onto the first cluster.
let mut x = 0.0;
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 g0 = first.g0;
let style = first.style;
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, dot: false });
}
}
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let doc = env.doc;
let mut rp = doc.styles.resolve_para(&p.props);
// List level indents apply unless the paragraph sets its own.
if let Some((_, lvl)) = &env.label {
if p.props.indent_left.is_none() {
rp.indent_left = lvl.indent;
}
if p.props.indent_first.is_none() {
rp.indent_first = -lvl.hanging;
}
}
let para_style = p.props.style.as_deref();
let resolve = |c: &CharProps| -> Arc<ResolvedChar> {
match env.table_chr {
Some(t) => {
let merged = t.clone().overlaid(c);
Arc::new(doc.styles.resolve_char(para_style, &merged))
}
None => Arc::new(doc.styles.resolve_char(para_style, c)),
}
};
let mut b = Builder { env, styles: Vec::new(), style_index: Default::default(), glyphs: Vec::new(), clusters: 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;
for (range, props) in p.run_ranges() {
let rc = resolve(props);
let Some(text) = p.text.get(range.clone()) else { continue };
if rc.hidden && !b.env.show_hidden {
let si = b.style(&rc, None, false);
let g = b.glyphs.len() as u32;
for (i, c) in text.char_indices() {
if c == OBJ {
obj_index += 1;
}
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,
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();
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).clamp(rc.size, 1000.0);
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));
// 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, 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(InlineObject::Image { w, h, float, .. }) | Some(InlineObject::Shape { w, h, float, .. }) => {
if float.wrap == wordcraft_doc::para::Wrap::Inline {
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 w > maxw { maxw / w } else { 1.0 };
push(&mut b, ClKind::Object(k), w * s, h * s);
} else {
push(&mut b, ClKind::Object(k), 0.0, 0.0);
}
}
Some(InlineObject::Field { instr, result, .. }) => {
let (t, page_dep) = field_text(instr, result, env.fields);
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 n = env.fields.note_number(*id);
match kind {
NoteKind::Footnote => doc.settings.footnote_format.format(n),
NoteKind::Endnote => doc.settings.endnote_format.format(n),
}
} 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, .. }) => {
let mut eq = (*rc).clone();
eq.italic = true;
eq.font = "Cambria Math".into();
b.shape_atomic(linear, start, end, &Arc::new(eq));
}
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);
}
}
// Break opportunities.
let mut opps = std::collections::HashSet::new();
for (i, o) in unicode_linebreak::linebreaks(&p.text) {
if o == unicode_linebreak::BreakOpportunity::Allowed || i < p.text.len() {
opps.insert(i);
}
}
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);
let before_c = b.clusters.len();
let before_g = b.glyphs.len();
b.shape(text, 0, &rc, None);
let cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
let mut glyphs = Vec::new();
let mut x = 0.0;
let mut style = mark_style;
for c in &cl {
style = c.style;
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 });
}
x += c.adv;
}
b.glyphs.truncate(before_g);
Label { text: text.clone(), style, glyphs, x: 0.0, width: x }
});
let mut pl = ParaLayout {
rp,
styles: b.styles,
glyphs: b.glyphs,
clusters: b.clusters,
lines: Vec::new(),
label,
height: 0.0,
text_len: p.text.len(),
has_page_fields,
notes,
issues: if env.proofing { proof_issues(p) } else { Vec::new() },
drop_cap,
hyph_after: Vec::new(),
};
pl.hyph_after = hyphenation_points(p, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens);
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.suffix));
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 };
let n = ((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: u16, suffix: Option<LevelSuffix>) {
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;
let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None };
let n = pl.clusters.len();
let mut hcache: Vec<(u16, 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);
loop {
let left;
let right_edge;
// Flow around floating objects: narrow the line or move it below them.
let mut guard = 0;
loop {
let (mut lo, mut hi) = (if first { first_left } else { rp.indent_left }, base_right);
if let Some((_, dl, dw)) = pl.drop_cap
&& lines.len() < dl as usize
{
lo = first_left.max(rp.indent_left) + dw;
}
let mut push: Option<f32> = None;
for e in env.exclusions {
if e.bottom <= top || e.top >= top + est_h {
continue;
}
if e.top_bottom || (e.left <= lo + 1.0 && e.right >= hi - 1.0) {
push = Some(push.map_or(e.bottom, |p: f32| p.max(e.bottom)));
continue;
}
if (e.left + e.right) / 2.0 < (lo + hi) / 2.0 {
lo = lo.max(e.right);
} else {
hi = hi.min(e.left);
}
}
if push.is_none() && hi - lo < 36.0 {
push = env
.exclusions
.iter()
.filter(|e| e.bottom > top && e.top < top + est_h)
.map(|e| e.bottom)
.fold(None, |a: Option<f32>, b| Some(a.map_or(b, |a| a.min(b))));
}
match push {
Some(y) if y > top && guard < 50 => {
top = y;
guard += 1;
}
_ => {
left = lo;
right_edge = hi.max(lo + 12.0);
break;
}
}
}
let mut x = left;
// Label on the first line.
if first && let Some(lab) = pl.label.as_mut() {
lab.x = left;
let end = left + lab.width;
x = match suffix.unwrap_or(LevelSuffix::Tab) {
LevelSuffix::Tab => {
let t = next_tab(end, &rp.tabs, default_tab, hanging_at);
t.pos
}
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;
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
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
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 => {
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;
}
_ => {}
}
// 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 fits = absorbs || x + c.adv <= right_edge + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
if !fits && j > c0 {
// 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 <= HYPHENATION_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).
}
break;
}
xs.push(x);
x += 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 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 x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + 0.01 {
last_break = Some(j);
}
}
j += 1;
}
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);
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() {
if c.kind == ClKind::Marker || c0 + k < dropped {
continue;
}
if let Some(st) = pl.styles.get(c.style as usize) {
let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h > 0.0 {
(c.obj_h, 0.0)
} else {
(st.ascent + st.shift.max(0.0), 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;
}
if first
&& let Some(lab) = &pl.label
&& let Some(st) = pl.styles.get(lab.style as usize)
{
asc = asc.max(st.ascent);
desc = desc.max(st.descent);
}
let natural = asc + desc;
let height = match rp.line_spacing {
LineSpacing::Multiple(m) => natural * m,
LineSpacing::AtLeast(v) => natural.max(v),
LineSpacing::Exactly(v) => v,
};
let baseline = top + 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 shift = match rp.align {
Align::Center => slack / 2.0,
Align::Right => slack,
_ => 0.0,
};
if shift > 0.0 && slack > 0.0 {
for v in xs.iter_mut().skip(align_from - c0) {
*v += shift;
}
}
let justify = (rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute;
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 };
lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left, right: right_edge, hyphen });
top += height;
first = false;
i = j;
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 {
LineSpacing::Multiple(m) => natural * m,
LineSpacing::AtLeast(v) => natural.max(v),
LineSpacing::Exactly(v) => v,
};
let x0 = rp.indent_left;
lines.push(Line {
top,
height: h,
baseline: top + h - desc,
c0: n,
c1: n,
xs: vec![x0],
leaders: Vec::new(),
end: LineEnd::Para,
start: pl.text_len,
stop: pl.text_len,
left: x0,
right: base_right,
hyphen: None,
});
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(l0.xs.first().copied().unwrap_or(0.0) - first_left.max(rp.indent_left));
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;
}
}
}
pl.height = top;
pl.lines = lines;
}
/// Word's default hyphenation zone (0.25"), points.
const HYPHENATION_ZONE: f32 = 18.0;
/// The hyphen glyph and advance in a style (cached per paragraph).
fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) -> (u16, 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 when automatic hyphenation is on.
fn hyphenation_points(p: &Paragraph, pl: &ParaLayout, auto: bool) -> Vec<u32> {
let mut bytes: Vec<usize> = p.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;
let text = &p.text;
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) => {
if let Some(w) = text.get(a..i)
&& w.chars().count() >= lim.min_word
{
let offs: Vec<usize> = w.char_indices().map(|(o, _)| o).collect();
for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) {
if let Some(o) = offs.get(pt) {
bytes.push(a + o);
}
}
}
start = None;
}
_ => {}
}
}
}
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 {
/// 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)?;
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)
}
/// 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 };
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)
}
}
/// Spelling and grammar issues in a paragraph (skipping "do not check" and hidden runs).
fn proof_issues(p: &Paragraph) -> Vec<(usize, usize, bool)> {
if p.text.trim().is_empty() || p.text.len() > 100_000 {
return Vec::new();
}
let text = proof_text(p);
let skip = |a: usize, b: usize| {
p.run_ranges().any(|(r, c)| r.start < b && a < r.end && (c.no_proof == Some(true) || c.hidden == Some(true) || c.link.is_some()))
};
let mut v: Vec<(usize, usize, bool)> =
wordcraft_proof::check_spelling(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, false)).collect();
v.extend(wordcraft_proof::check_grammar(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, true)));
v
}
/// 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
}