From 4de3a4dc8b2f86af9cd1faa32e63d6ead600b64e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:02:27 +0000 Subject: [PATCH] Refactor text for strict Clippy checks --- crates/text/src/bidi.rs | 216 ++++--- crates/text/src/breaking.rs | 25 +- crates/text/src/layout.rs | 1193 ++++++++++++++++++++--------------- crates/text/src/run.rs | 2 +- crates/text/src/shape.rs | 5 +- crates/text/src/style.rs | 2 +- crates/text/src/symbol.rs | 2 +- 7 files changed, 826 insertions(+), 619 deletions(-) diff --git a/crates/text/src/bidi.rs b/crates/text/src/bidi.rs index ac9b3afd..cc8d0aac 100644 --- a/crates/text/src/bidi.rs +++ b/crates/text/src/bidi.rs @@ -66,109 +66,16 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) { return levels(text, chars, rtl); } - let n = chars.len(); - let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false); - let sos = if rtl { Dir::R } else { Dir::L }; - // W1: combining marks take the class of the character they sit on. - let mut cls = classes; - for i in 0..n { - if cls.get(i) == Some(&NSM) { - let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; - if let Some(c) = cls.get_mut(i) { - *c = prev; - } - } - } - let mut dir = vec![Dir::Open; n]; - // Strong letters and digits. - let mut last_strong: Option = None; - for i in 0..n { - let c = cls.get(i).copied().unwrap_or(ON); - let d = match c { - L => Dir::L, - R | AL => Dir::R, - AN => Dir::Num, - EN if !rl(i) => Dir::L, - // W7: European digits after left-to-right text are left to right. - EN if last_strong == Some(L) => Dir::L, - EN => Dir::Num, - _ => Dir::Open, - }; - if matches!(c, L | R | AL) { - last_strong = Some(c); - } - if let Some(x) = dir.get_mut(i) { - *x = d; - } - } - // W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next - // to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, - // computed once so a long run of currency signs costs linear work, not a scan per sign. - let after_et = next_non_et(&cls); - for i in 0..n { - if !rl(i) || dir.get(i) != Some(&Dir::Open) { - continue; - } - let c = cls.get(i).copied().unwrap_or(ON); - let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); - let joined = match c { - ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), - ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), - _ => false, - }; - if joined && let Some(x) = dir.get_mut(i) { - *x = Dir::Num; - } - } + let cls = resolve_nsm(classes, rtl); + let mut dir = initial_dirs(&cls, rtl_lang); + join_numbers_in_rtl_runs(&mut dir, &cls, rtl_lang); // Punctuation in left-to-right runs is left to right. - for i in 0..n { - if !rl(i) - && dir.get(i) == Some(&Dir::Open) - && !matches!(cls.get(i), Some(WS | S)) - && let Some(x) = dir.get_mut(i) - { - *x = Dir::L; - } - } - // Neutrals in right-to-left runs: the direction around them when both sides agree, else - // right to left. Unresolved spaces of left-to-right runs are looked through. - let strong_at = |dir: &[Dir], j: usize| match dir.get(j) { - Some(Dir::L) => Some(Dir::L), - Some(Dir::R | Dir::Num) => Some(Dir::R), - _ => None, - }; - // Resolving a span only changes characters before the scan position, so the nearest strong - // direction after a span can be computed once up front, and the one before it is carried along: - // linear work however many neutral spans the paragraph has. - let mut next_strong: Vec> = vec![None; n + 1]; - for j in (0..n).rev() { - let here = strong_at(&dir, j).or(next_strong.get(j + 1).copied().flatten()); - if let Some(x) = next_strong.get_mut(j) { - *x = here; - } - } - let mut last_strong: Option = None; - let mut i = 0; - while i < n { - if !(rl(i) && dir.get(i) == Some(&Dir::Open)) { - last_strong = strong_at(&dir, i).or(last_strong); - i += 1; - continue; - } - let start = i; - while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) { - i += 1; - } - let before = last_strong.unwrap_or(sos); - let after = next_strong.get(i).copied().flatten().unwrap_or(sos); - let d = if before == after { before } else { Dir::R }; - for x in dir.get_mut(start..i).into_iter().flatten() { - *x = d; - } - if start < i { - last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + for (i, d) in dir.iter_mut().enumerate() { + if !in_rtl_lang(rtl_lang, i) && *d == Dir::Open && !matches!(cls.get(i), Some(WS | S)) { + *d = Dir::L; } } + resolve_rtl_neutrals(&mut dir, rtl_lang, if rtl { Dir::R } else { Dir::L }); // Spaces in left-to-right runs follow the character before them. let mut prev = Dir::L; for d in dir.iter_mut() { @@ -187,6 +94,115 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo .collect() } +fn in_rtl_lang(rtl_lang: &[bool], i: usize) -> bool { + rtl_lang.get(i).copied().unwrap_or(false) +} + +/// W1: combining marks take the class of the character they sit on. +fn resolve_nsm(mut cls: Vec, rtl: bool) -> Vec { + for i in 0..cls.len() { + if cls.get(i) == Some(&NSM) { + let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; + if let Some(c) = cls.get_mut(i) { + *c = prev; + } + } + } + cls +} + +/// Strong letters and digits get a direction; everything else stays open. +fn initial_dirs(cls: &[BidiClass], rtl_lang: &[bool]) -> Vec { + let mut last_strong: Option = None; + cls.iter() + .enumerate() + .map(|(i, &c)| { + let d = match c { + L => Dir::L, + R | AL => Dir::R, + AN => Dir::Num, + EN if !in_rtl_lang(rtl_lang, i) => Dir::L, + // W7: European digits after left-to-right text are left to right. + EN if last_strong == Some(L) => Dir::L, + EN => Dir::Num, + _ => Dir::Open, + }; + if matches!(c, L | R | AL) { + last_strong = Some(c); + } + d + }) + .collect() +} + +/// W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next +/// to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, +/// computed once so a long run of currency signs costs linear work, not a scan per sign. +fn join_numbers_in_rtl_runs(dir: &mut [Dir], cls: &[BidiClass], rtl_lang: &[bool]) { + let after_et = next_non_et(cls); + for i in 0..dir.len() { + if !in_rtl_lang(rtl_lang, i) || dir.get(i) != Some(&Dir::Open) { + continue; + } + let c = cls.get(i).copied().unwrap_or(ON); + let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); + let joined = match c { + ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), + ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), + _ => false, + }; + if joined && let Some(x) = dir.get_mut(i) { + *x = Dir::Num; + } + } +} + +fn strong_at(dir: &[Dir], j: usize) -> Option { + match dir.get(j) { + Some(Dir::L) => Some(Dir::L), + Some(Dir::R | Dir::Num) => Some(Dir::R), + _ => None, + } +} + +/// Neutrals in right-to-left runs: the direction around them when both sides agree, else right to +/// left. Unresolved spaces of left-to-right runs are looked through. +/// +/// Resolving a span only changes characters before the scan position, so the nearest strong +/// direction after a span can be computed once up front, and the one before it is carried along: +/// linear work however many neutral spans the paragraph has. +fn resolve_rtl_neutrals(dir: &mut [Dir], rtl_lang: &[bool], sos: Dir) { + let n = dir.len(); + let rl = |dir: &[Dir], i: usize| in_rtl_lang(rtl_lang, i) && dir.get(i) == Some(&Dir::Open); + let mut next_strong: Vec> = vec![None; n + 1]; + for j in (0..n).rev() { + let here = strong_at(dir, j).or(next_strong.get(j + 1).copied().flatten()); + if let Some(x) = next_strong.get_mut(j) { + *x = here; + } + } + let mut last_strong: Option = None; + let mut i = 0; + while i < n { + if !rl(dir, i) { + last_strong = strong_at(dir, i).or(last_strong); + i += 1; + continue; + } + let start = i; + while i < n && rl(dir, i) { + i += 1; + } + let before = last_strong.unwrap_or(sos); + let after = next_strong.get(i).copied().flatten().unwrap_or(sos); + let d = if before == after { before } else { Dir::R }; + for x in dir.get_mut(start..i).into_iter().flatten() { + *x = d; + } + last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + } +} + /// For each index, the first later index whose class is not ET (`None` when only ET follows). fn next_non_et(cls: &[BidiClass]) -> Vec> { let mut out = vec![None; cls.len()]; diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs index 133eabf5..36b9e592 100644 --- a/crates/text/src/breaking.rs +++ b/crates/text/src/breaking.rs @@ -1,5 +1,5 @@ use super::style::{Cell, ParaShaped}; -use super::*; +use super::{FontFace, ParaProps}; pub(crate) fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { let g = deckcraft_fonts::shape(face, s, &[], |c| c); @@ -132,16 +132,19 @@ pub(crate) struct RawLine { next: usize, } -pub(crate) fn break_lines( - p: &ParaShaped, - first_w: f64, - rest_w: f64, - wrap: bool, - tab_stops: &[Tab], - default_tab: f64, - x0_first: f64, - x0_rest: f64, -) -> Vec { +/// Wrapping geometry of one paragraph: line widths, start offsets, wrap flag and tab settings. +pub(crate) struct BreakParams<'a> { + pub(crate) first_w: f64, + pub(crate) rest_w: f64, + pub(crate) wrap: bool, + pub(crate) tab_stops: &'a [Tab], + pub(crate) default_tab: f64, + pub(crate) x0_first: f64, + pub(crate) x0_rest: f64, +} + +pub(crate) fn break_lines(p: &ParaShaped, params: &BreakParams) -> Vec { + let BreakParams { first_w, rest_w, wrap, tab_stops, default_tab, x0_first, x0_rest } = *params; let text: String = p.cells.iter().map(|c| c.ch).collect(); let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); let hang = p.props.hanging_punct.unwrap_or(false); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c6b5b1a0..f764cf52 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1,8 +1,11 @@ -use super::breaking::{break_lines, line_metrics, measure_str, tab_advance, tab_segment, tabs_of}; +use super::breaking::{BreakParams, RawLine, Tab, break_lines, line_metrics, measure_str, tab_advance, tab_segment, tabs_of}; use super::run::{Deco, DecoLine, GlyphRun}; use super::shape::{SUB_SUPER, Upright, shape_para}; -use super::style::{Opts, ParaShaped, spacing_pts}; -use super::*; +use super::style::{Cell, Opts, ParaShaped, Style, spacing_pts}; +use super::{ + Align, Anchor, Arc, AutoFit, BodyProps, Bullet, Caps, Ctx, FontFace, LineInfo, Rect, Shape, Spacing, Strike, TextBody, TextDir, TextLayout, bidi, + joins_following, joins_preceding, resolve, +}; #[cfg(test)] thread_local! { @@ -63,520 +66,176 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay l } -#[allow(clippy::too_many_arguments)] -pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { - #[cfg(test)] - LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); - let r = opts.rect; - let vert = bp.vert.unwrap_or(TextDir::Horizontal); - // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text - // stands every character upright, one under the other. - let upright_text = match vert { - TextDir::EaVertical => Upright::Cjk, - TextDir::Stacked | TextDir::StackedRtl => Upright::All, - _ => Upright::None, - }; - // Mongolian vertical and stacked text: turned like vert, their lines run from left to right - // (as PowerPoint draws both). - let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked); - let rotation = match vert { - TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, - TextDir::Vertical270 => 270.0, - TextDir::Horizontal => 0.0, - } + bp.rot.unwrap_or(0.0); - // For vertical text, lay out in a box with swapped dimensions centred on the text rect. - let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 { - let c = r.center(); - Rect::new(c.x - r.height() / 2.0, c.y - r.width() / 2.0, c.x + r.height() / 2.0, c.y + r.width() / 2.0) - } else { - r - }; - let inner = Rect::new( - r.x0 + bp.inset_l.unwrap_or(7.2), - r.y0 + bp.inset_t.unwrap_or(3.6), - (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), - (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), - ); - // Text that follows a WordArt arch or circle is set on one line per paragraph. - let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); - let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); - let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; - let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); - let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); +/// Geometry of the text box shared by every line: rotation, inner rectangle, wrapping and columns. +struct BoxGeom { + vert: TextDir, + rotation: f64, + inner: Rect, + wrap: bool, + ncols: usize, + col_gap: f64, + col_w: f64, + rtl_cols: bool, +} - let mut out = TextLayout { inner, font_scale: scale, line_reduction: reduce, rotation, ..Default::default() }; - let mut y = 0.0; +impl BoxGeom { + fn new(shape: &Shape, bp: &BodyProps, text_rect: Rect) -> Self { + let vert = bp.vert.unwrap_or(TextDir::Horizontal); + let rotation = match vert { + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, + TextDir::Vertical270 => 270.0, + TextDir::Horizontal => 0.0, + } + bp.rot.unwrap_or(0.0); + // For vertical text, lay out in a box with swapped dimensions centred on the text rect. + let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 { + let c = text_rect.center(); + Rect::new(c.x - text_rect.height() / 2.0, c.y - text_rect.width() / 2.0, c.x + text_rect.height() / 2.0, c.y + text_rect.width() / 2.0) + } else { + text_rect + }; + let inner = Rect::new( + r.x0 + bp.inset_l.unwrap_or(7.2), + r.y0 + bp.inset_t.unwrap_or(3.6), + (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), + (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), + ); + // Text that follows a WordArt arch or circle is set on one line per paragraph. + let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); + let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); + let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; + let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); + let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); + let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; + BoxGeom { vert, rotation, inner, wrap, ncols, col_gap, col_w, rtl_cols } + } + + /// East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text + /// stands every character upright, one under the other. + fn upright(&self) -> Upright { + match self.vert { + TextDir::EaVertical => Upright::Cjk, + TextDir::Stacked | TextDir::StackedRtl => Upright::All, + _ => Upright::None, + } + } + + /// Mongolian vertical and stacked text: turned like vert, their lines run from left to right + /// (as PowerPoint draws both). + fn lines_ltr(&self) -> bool { + matches!(self.vert, TextDir::MongolianVertical | TextDir::Stacked) + } + + /// Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the + /// first column on the right). + fn col_x(&self, c: usize) -> f64 { + (if self.rtl_cols { self.ncols.saturating_sub(1 + c) } else { c }) as f64 * (self.col_w + self.col_gap) + } +} + +/// Number shown for a paragraph at list level `lvl`, advancing the per-level counters. +fn next_number(numbering: &mut [u32], lvl: usize, auto_num: bool, empty: bool) -> Option { + if auto_num && !empty { + if let Some(n) = numbering.get_mut(lvl) { + *n += 1; + } + for deeper in numbering.iter_mut().skip(lvl + 1) { + *deeper = 0; + } + numbering.get(lvl).copied() + } else if auto_num { + // Preview the next number without consuming it: empty paragraphs do not advance + // numbering in the saved document or affect later populated paragraphs. + numbering.get(lvl).map(|n| n.saturating_add(1)) + } else { + if !empty { + for n in numbering.iter_mut().skip(lvl) { + *n = 0; + } + } + None + } +} + +fn shape_paragraphs(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts, scale: f64, upright: Upright) -> Vec { let mut numbering: Vec = vec![0; 9]; - let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![]; - let mut prev_after = 0.0; - // How far the last line's full pitch reaches past the text (exact spacing; see below). - let mut tail = 0.0; - let first_last = bp.space_first_last.unwrap_or(false); let mut shaped_paras: Vec = Vec::with_capacity(body.paragraphs.len()); for (pi, para) in body.paragraphs.iter().enumerate() { let lvl = para.level.min(8) as usize; let pp = resolve::para(ctx, shape, para); - let number = if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) && !para.is_empty() { - if let Some(n) = numbering.get_mut(lvl) { - *n += 1; - } - for deeper in numbering.iter_mut().skip(lvl + 1) { - *deeper = 0; - } - numbering.get(lvl).copied() - } else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) { - // Preview the next number without consuming it: empty paragraphs do not advance - // numbering in the saved document or affect later populated paragraphs. - numbering.get(lvl).map(|n| n.saturating_add(1)) - } else { - if !para.is_empty() { - for n in numbering.iter_mut().skip(lvl) { - *n = 0; - } - } - None - }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text)); + let auto_num = matches!(pp.bullet, Some(Bullet::AutoNum { .. })); + let number = next_number(&mut numbering, lvl, auto_num, para.is_empty()); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright)); } + shaped_paras +} + +/// A laid-out line with its placement data and, on a paragraph's first line, its bullet. +struct PlacedLine { + info: LineInfo, + place: LinePlace, + bullet: Option<(String, f64, f64)>, + /// Width of the line's text plus its start offset and right margin. + content_w: f64, +} + +/// Vertical flow state while paragraphs are stacked. +struct Flow { + y: f64, + prev_after: f64, + /// How far the last line's full pitch reaches past the text (exact spacing). + tail: f64, + lines: Vec, +} + +/// Measurements shared by the lines of one paragraph. +struct ParaEnv<'a> { + p: &'a ParaShaped, + g: &'a BoxGeom, + reduce: f64, + margin: f64, + indent: f64, + mr: f64, + bullet_w: f64, + first_x: f64, + rest_x: f64, + tabs: &'a [Tab], +} + +impl ParaEnv<'_> { + fn start_x(&self, li: usize) -> f64 { + if li == 0 { self.first_x } else { self.rest_x } + } +} + +pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { + #[cfg(test)] + LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); + let g = BoxGeom::new(shape, bp, opts.rect); + let shaped_paras = shape_paragraphs(ctx, shape, body, opts, scale, g.upright()); + let mut out = TextLayout { inner: g.inner, font_scale: scale, line_reduction: reduce, rotation: g.rotation, ..Default::default() }; + let first_last = bp.space_first_last.unwrap_or(false); + let mut flow = Flow { y: 0.0, prev_after: 0.0, tail: 0.0, lines: vec![] }; for (pi, p) in shaped_paras.iter().enumerate() { - let props = &p.props; - let margin = props.margin_left.unwrap_or(0.0).max(0.0); - let indent = props.indent.unwrap_or(0.0); - let mr = props.margin_right.unwrap_or(0.0).max(0.0); - let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); - let first_x = if p.bullet.is_some() { - let bx = (margin + indent).max(0.0); - // A hanging bullet's text starts at the margin. When the hang reaches left of the box - // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the - // hang: the first line's text starts at -indent (later lines stay at the margin). - let stop = margin.max(-indent); - if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } - } else { - (margin + indent).max(0.0) - }; - let rest_x = margin; - let tabs = tabs_of(props); - let first_w = (col_w - first_x - mr).max(1.0); - let rest_w = (col_w - rest_x - mr).max(1.0); - let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x); - // The first paragraph's space before counts only when the body says so. - let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; - y += before.max(0.0) + prev_after; - let first_line_idx = all_lines.len(); - for (li, rl) in raw.iter().enumerate() { - // Line metrics: max ascent/descent over the line's styles (or the lead style if empty). - let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64); - for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { - if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { - let sz = st.size; - // The run's own fonts set the line; a fallback face (emoji, a symbol or a - // script the run's fonts lack) does not move the baseline in PowerPoint. - let own = Arc::ptr_eq(face, &st.face) - || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); - let (a, d) = line_metrics(if own { face } else { &st.face }, sz); - asc = asc.max(a); - desc = desc.max(d); - size = size.max(sz); - } - } - if size == 0.0 { - // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the - // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not - // by the paragraph's first run (#780). An empty *paragraph* already lands on - // `end_props` through `lead`, and keeps the same height here. - let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); - let (face, sz) = match brk { - Some(st) => (&st.face, st.size), - None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), - // Anything else empty (a line of tabs alone) keeps the old measure. - None => (&p.lead_face, p.lead_size), - }; - let (a, d) = line_metrics(face, sz); - asc = a; - desc = d; - size = sz; - } - let natural = (asc + desc).max(size * 1.0); - let lh = match props.line_spacing { - // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint - // applies `lnSpcReduction`. - Some(Spacing::Pct(f)) => natural * (f - reduce).max(f * 0.1).max(0.0), - Some(Spacing::Pts(v)) => v * (1.0 - reduce), - None => natural * (1.0 - reduce), - }; - let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; - let top = y; - // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the - // line pitch, and the text ends at the last line's descent rather than a full pitch. - let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); - let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; - tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; - y += lh; - let x0 = if li == 0 { first_x } else { rest_x }; - let avail = (col_w - x0 - mr).max(0.0); - let n = rl.end.saturating_sub(rl.start); - let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); - // Logical advances; tabs measure from the start edge. - let mut adv = Vec::with_capacity(n); - let mut x = 0.0; - // A line that breaks at a soft hyphen ends with a visible hyphen. - let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); - for (k, c) in line_cells.iter().enumerate() { - let a = if shy_end && k + 1 == n { - p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) - } else if c.ch == '\t' { - tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) - } else { - c.adv - }; - adv.push(a); - x += a; - } - let width = x; - let align = props.align.unwrap_or(Align::Left); - let last_line = li + 1 == raw.len(); - let slack = (avail - width).max(0.0); - // Justification: kashida (justLow, Arabic), spaces, or every character. - let stretch = !last_line && wrap && slack > 0.0; - let mut kashida = vec![0.0; n]; - let mut filled = false; - if stretch && align == Align::JustLow { - let ops = kashida_opportunities(p, rl.start, rl.end); - if !ops.is_empty() { - let each = slack / ops.len() as f64; - for k in ops { - if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) { - *e = each; - *a += each; - } - } - filled = true; - } - } - if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { - let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); - if spaces > 0 { - for (a, c) in adv.iter_mut().zip(line_cells) { - if c.ch == ' ' { - *a += slack / spaces as f64; - } - } - filled = true; - } - } - if align == Align::Distributed && n > 1 { - for a in adv.iter_mut().take(n - 1) { - *a += slack / (n - 1) as f64; - } - filled = true; - } - let line_w: f64 = adv.iter().sum(); - // The line box, in column coordinates: the start edge is the left one for LTR and the - // right one (S = W - rIns) for RTL paragraphs. - let (box_l, box_r) = if p.rtl { (mr, col_w - x0) } else { (x0, col_w - mr) }; - let left = if filled { - box_l - } else { - match align { - Align::Left => box_l, - Align::Right => box_r - line_w, - Align::Center => box_l + (box_r - box_l - line_w) / 2.0, - Align::Distributed if n <= 1 => box_l + slack / 2.0, - // Justified lines that aren't stretched (the last one) sit at the start edge. - Align::Justify | Align::JustLow | Align::Distributed if p.rtl => box_r - line_w, - Align::Justify | Align::JustLow | Align::Distributed => box_l, - } - }; - // Distance from the box's start edge to the text (bullets move with centred or - // end-aligned text). - let content_shift = if p.rtl { box_r - (left + line_w) } else { left - box_l }; - // Rules L1 + L2: this line's visual order. - let chars: Vec = line_cells.iter().map(|c| c.ch).collect(); - let raw_levels: Vec = line_cells.iter().map(|c| c.level).collect(); - let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl)); - let order = bidi::visual_order(&levels); - let mut edges = vec![(left, left); n]; - let mut vx = left; - for &k in &order { - let a = adv.get(k).copied().unwrap_or(0.0); - if let Some(e) = edges.get_mut(k) { - *e = (vx, vx + a); - } - vx += a; - } - let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1); - let mut caret_x: Vec = (0..n).map(|k| edges.get(k).map(|e| if odd(k) { e.1 } else { e.0 }).unwrap_or(left)).collect(); - caret_x.push(match n.checked_sub(1) { - Some(k) => edges.get(k).map(|e| if odd(k) { e.0 } else { e.1 }).unwrap_or(left), - None if p.rtl => left + line_w, - None => left, - }); - let bullet = if li == 0 { - p.bullet.as_ref().map(|(s, _)| { - // Centred and end-aligned text carries its bullet along. - let end_aligned = if p.rtl { align == Align::Left } else { align == Align::Right }; - let shifted = align == Align::Center || end_aligned; - let b = if shifted && p.cells.is_empty() { - content_shift + x0 - bullet_w - } else { - (margin + indent).max(0.0) + if shifted { content_shift } else { 0.0 } - }; - // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right. - let bx = if p.rtl { col_w - b - bullet_w } else { b }; - (s.clone(), bx, baseline) - }) - } else { - None - }; - out.content_width = out.content_width.max(width + x0 + mr); - let boundary: Vec = line_cells.iter().map(|c| c.boundary).collect(); - all_lines.push(( - LineInfo { - para: pi, - start: rl.start, - end: rl.end, - top, - baseline, - bottom: y, - caret_x, - column: 0, - rtl: p.rtl, - levels, - edges, - boundary, - extent: (left, left + line_w), - start_offset: x0, - }, - LinePlace { order, kashida }, - lh, - bullet, - )); - } - out.para_lines.push((first_line_idx, raw.len())); - prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); + place_paragraph(p, pi, &g, (reduce, first_last), &mut flow, &mut out); } // The text ends at the last line's descent under exact spacing; the last paragraph's space // after counts only when the body says so. - let total = (y - tail).max(0.0) + if first_last { prev_after } else { 0.0 }; + let total = (flow.y - flow.tail).max(0.0) + if first_last { flow.prev_after } else { 0.0 }; out.content_height = total; - // Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the - // first column on the right). - let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; - let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap); - // Columns: distribute lines by height. - let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY }; - let mut col = 0usize; - let mut col_y0 = 0.0; - for (li, _, _, _) in all_lines.iter_mut() { - if ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < ncols { - col += 1; - col_y0 = li.top; - } - li.column = col; - if col > 0 { - let dy = col_y0; - li.top -= dy; - li.baseline -= dy; - li.bottom -= dy; - } - let cx = col_x(col); - if cx != 0.0 { - li.shift_x(cx); - } - } - let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total }; - out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5)); - let anchor = bp.anchor.unwrap_or(Anchor::Top); - let dy = match anchor { - Anchor::Top => 0.0, - Anchor::Middle => (inner.height() - used_h) / 2.0, - // PowerPoint draws justified and distributed anchoring at the bottom. - Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, - }; - // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put - // them, so the origin is set so the block's centre lands on the box's centre — a left-edge - // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink - // and stay out of the block. Overflowing content centres symmetrically past both edges. - // Not applied to vertical text (there the offset would run along the wrong axis) or - // multi-column bodies (centring unimplemented for them). - let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal); - let ox = if centre_block && !all_lines.is_empty() { - let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start); - let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1))); - if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } - } else { - inner.x0 - }; - let oy = inner.y0 + dy; + let mut lines = flow.lines; + assign_columns(&mut lines, &g); + let used_h = if g.ncols > 1 { lines.iter().map(|l| l.info.bottom).fold(0.0, f64::max) } else { total }; + out.overflow = used_h > g.inner.height() + 0.5 || (g.ncols > 1 && lines.last().is_some_and(|l| l.info.bottom > g.inner.height() + 0.5)); + let (ox, oy) = text_origin(&lines, &g, bp, used_h); // Emit runs, in visual order. - for (line, place, _, bullet) in all_lines.iter_mut() { - line.top += oy; - line.baseline += oy; - line.bottom += oy; - line.shift_x(ox); - if lines_ltr { - // The line's place mirrored across the box: the first line ends up on the left. - let d = inner.y0 + inner.y1 - line.top - line.bottom; - line.top += d; - line.baseline += d; - line.bottom += d; - } - let Some(p) = shaped_paras.get(line.para) else { continue }; - if let Some((s, bx, _)) = bullet.take() - && let Some((_, st)) = p.bullet.as_ref() - { - let (_, g) = measure_str(&st.face, st.size, &s); - let run = GlyphRun { - face: st.face.clone(), - size: st.size, - color: st.color, - glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(), - cells: vec![], - fake_bold: false, - fake_italic: false, - outline: None, - para: line.para, - chars: (0, 0), - link: false, - alpha: st.alpha, - gradient: None, - effects: None, - upright: false, - }; - if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() { - out.empty_bullets.push(run); - } else { - out.runs.push(run); - } - } - // Group visually consecutive cells by (style, face). - let mut cur: Option = None; - let mut cur_key = (usize::MAX, usize::MAX, false, false); - for &k in &place.order { - let ci = line.start + k; - let Some(cell) = p.cells.get(ci) else { continue }; - let Some(st) = p.styles.get(cell.style) else { continue }; - let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; - let base_y = line.baseline - st.baseline * st.size; - let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; - let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; - // Small capitals are drawn smaller than the capitals beside them: a run of their own. - let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright); - if key != cur_key { - if let Some(r) = cur.take() { - out.runs.push(r); - } - let Some(face) = p.faces.get(cell.face) else { continue }; - cur = Some(GlyphRun { - face: face.clone(), - size, - color: st.color, - glyphs: vec![], - cells: vec![], - fake_bold: st.fake_bold, - fake_italic: st.fake_italic, - outline: st.outline, - para: line.para, - chars: (ci, ci + 1), - link: st.link, - alpha: st.alpha, - gradient: st.gradient.clone(), - effects: st.effects, - upright: cell.upright, - }); - cur_key = key; - } - if let Some(r) = cur.as_mut() { - r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); - if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { - // The soft hyphen a line breaks at is drawn as a hyphen. - if let Some(f) = p.faces.get(cell.face) { - r.glyphs.push((f.glyph_for('-'), cx, base_y)); - r.cells.push((ci, cell.ch)); - } - } else if cell.head == ci && !cell.glyphs.is_empty() { - // The cluster's glyphs start at its leftmost cell; kashida (right-to-left - // only) fills the gap on the cluster's left with tatweel glyphs. - let cells_of = - (k..line.end.saturating_sub(line.start)).take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci)); - let (mut cl, mut kash) = (cx, 0.0); - for j in cells_of { - cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx)); - kash += place.kashida.get(j).copied().unwrap_or(0.0); - } - if kash > 0.0 { - let face = p.faces.get(cell.face); - let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0); - let tw = face.map(|f| f.advance(tg) * size / f.upem.max(1.0)).unwrap_or(0.0); - if tg != 0 && tw > 0.0 { - let count = ((kash / tw).ceil() as usize).clamp(1, 256); - for i in 0..count { - r.glyphs.push((tg, cl + (i as f64 * tw).min((kash - tw).max(0.0)), base_y)); - r.cells.push((ci, cell.ch)); - } - } - } - for (gid, gx, gy) in &cell.glyphs { - r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); - r.cells.push((ci, cell.ch)); - } - } - } - let adv = cx1 - cx; - if adv <= 0.0 { - continue; - } - if let Some(hl) = st.highlight { - out.decos.push(Deco { - rect: Rect::new(cx, line.top, cx1, line.bottom), - color: hl, - behind: true, - para: line.para, - line: DecoLine::Solid, - }); - } - if !cell.ch.is_whitespace() || st.underline.is_some() { - if let Some((uc, u)) = st.underline { - // At the underline position and thickness of the run's own font. - let k = st.size / st.face.upem.max(1.0); - let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); - for (y0, y1, kind) in u.boxes(p, t) { - out.decos.push(Deco { - rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), - color: uc, - behind: false, - para: line.para, - line: kind, - }); - } - } - if let Some(sk) = st.strike { - // At the strikeout position and thickness of the run's own font. - let k = st.size / st.face.upem.max(1.0); - let t = (st.face.strikeout.1 * k).max(0.5); - let sy = line.baseline - st.face.strikeout.0 * k; - out.decos.push(Deco { - rect: Rect::new(cx, sy, cx1, sy + t), - color: st.color, - behind: false, - para: line.para, - line: DecoLine::Solid, - }); - if sk == Strike::Double { - out.decos.push(Deco { - rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), - color: st.color, - behind: false, - para: line.para, - line: DecoLine::Solid, - }); - } - } - } - } - if let Some(r) = cur.take() { - out.runs.push(r); + for mut pl in lines { + position_line(&mut pl.info, ox, oy, &g); + if let Some(p) = shaped_paras.get(pl.info.para) { + let to_empty = body.paragraphs.get(pl.info.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none(); + emit_bullet(&mut out, p, &pl, ox + g.col_x(pl.info.column), to_empty); + emit_line_runs(&mut out, p, &pl.info, &pl.place); } + out.lines.push(pl.info); } out.boundaries = shaped_paras .iter() @@ -589,12 +248,538 @@ pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &Body b }) .collect(); - out.lines = all_lines.into_iter().map(|(l, ..)| l).collect(); // Merge adjacent decorations of the same colour on the same line. out.decos = merge_decos(std::mem::take(&mut out.decos)); out } +/// Where the first line's text starts: past the bullet when there is one. +fn first_line_start(has_bullet: bool, margin: f64, indent: f64, bullet_w: f64) -> f64 { + if !has_bullet { + return (margin + indent).max(0.0); + } + let bx = (margin + indent).max(0.0); + // A hanging bullet's text starts at the margin. When the hang reaches left of the box + // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the + // hang: the first line's text starts at -indent (later lines stay at the margin). + let stop = margin.max(-indent); + if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } +} + +/// Breaks one paragraph into lines and stacks them onto `flow`. `spacing` is +/// (line spacing reduction, whether the first paragraph's space before counts). +fn place_paragraph(p: &ParaShaped, pi: usize, g: &BoxGeom, spacing: (f64, bool), flow: &mut Flow, out: &mut TextLayout) { + let (reduce, first_last) = spacing; + let props = &p.props; + let margin = props.margin_left.unwrap_or(0.0).max(0.0); + let indent = props.indent.unwrap_or(0.0); + let mr = props.margin_right.unwrap_or(0.0).max(0.0); + let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); + let first_x = first_line_start(p.bullet.is_some(), margin, indent, bullet_w); + let rest_x = margin; + let tabs = tabs_of(props); + let first_w = (g.col_w - first_x - mr).max(1.0); + let rest_w = (g.col_w - rest_x - mr).max(1.0); + let raw = break_lines( + p, + &BreakParams { + first_w, + rest_w, + wrap: g.wrap, + tab_stops: &tabs, + default_tab: props.default_tab.unwrap_or(72.0), + x0_first: first_x, + x0_rest: rest_x, + }, + ); + // The first paragraph's space before counts only when the body says so. + let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; + flow.y += before.max(0.0) + flow.prev_after; + let first_line_idx = flow.lines.len(); + let env = ParaEnv { p, g, reduce, margin, indent, mr, bullet_w, first_x, rest_x, tabs: &tabs }; + for (li, rl) in raw.iter().enumerate() { + let is_last = li + 1 == raw.len(); + let placed = place_line(&env, pi, rl, li, is_last, flow); + out.content_width = out.content_width.max(placed.content_w); + flow.lines.push(placed); + } + out.para_lines.push((first_line_idx, raw.len())); + flow.prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); +} + +/// Max ascent / descent / size over the line's styles (or the lead style if empty). +fn line_extent(p: &ParaShaped, rl: &RawLine) -> (f64, f64, f64) { + let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64); + for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { + if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { + let sz = st.size; + // The run's own fonts set the line; a fallback face (emoji, a symbol or a + // script the run's fonts lack) does not move the baseline in PowerPoint. + let own = Arc::ptr_eq(face, &st.face) + || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); + let (a, d) = line_metrics(if own { face } else { &st.face }, sz); + asc = asc.max(a); + desc = desc.max(d); + size = size.max(sz); + } + } + if size == 0.0 { + // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the + // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not + // by the paragraph's first run (#780). An empty *paragraph* already lands on + // `end_props` through `lead`, and keeps the same height here. + let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); + let (face, sz) = match brk { + Some(st) => (&st.face, st.size), + None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), + // Anything else empty (a line of tabs alone) keeps the old measure. + None => (&p.lead_face, p.lead_size), + }; + let (a, d) = line_metrics(face, sz); + return (a, d, sz); + } + (asc, desc, size) +} + +/// Logical advance of each cell on the line (tabs measure from the start edge) and their sum. +fn line_advances(env: &ParaEnv, rl: &RawLine, is_last: bool, x0: f64) -> (Vec, f64) { + let p = env.p; + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let n = rl.end.saturating_sub(rl.start); + let mut adv = Vec::with_capacity(n); + let mut x = 0.0; + // A line that breaks at a soft hyphen ends with a visible hyphen. + let shy_end = !is_last && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); + for (k, c) in line_cells.iter().enumerate() { + let a = if shy_end && k + 1 == n { + p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) + } else if c.ch == '\t' { + tab_advance(x0 + x, env.tabs, p.props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) + } else { + c.adv + }; + adv.push(a); + x += a; + } + (adv, x) +} + +/// Justification: kashida (justLow, Arabic), spaces, or every character. Widens `adv` and returns +/// the kashida widths and whether the line was filled to the box edges. +fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, slack: f64, stretch: bool) -> (Vec, bool) { + let n = adv.len(); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let mut kashida = vec![0.0; n]; + let mut filled = false; + if stretch && align == Align::JustLow { + let ops = kashida_opportunities(p, rl.start, rl.end); + if !ops.is_empty() { + let each = slack / ops.len() as f64; + for k in ops { + if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) { + *e = each; + *a += each; + } + } + filled = true; + } + } + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { + let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); + if spaces > 0 { + for (a, c) in adv.iter_mut().zip(line_cells) { + if c.ch == ' ' { + *a += slack / spaces as f64; + } + } + filled = true; + } + } + if align == Align::Distributed && n > 1 { + for a in adv.iter_mut().take(n - 1) { + *a += slack / (n - 1) as f64; + } + filled = true; + } + (kashida, filled) +} + +/// Left edge of the line's text inside its box `(box_l, box_r)`. +fn line_left(align: Align, filled: bool, rtl: bool, bx: (f64, f64), line_w: f64, slack: f64, n: usize) -> f64 { + let (box_l, box_r) = bx; + if filled { + return box_l; + } + match align { + Align::Left => box_l, + Align::Right => box_r - line_w, + Align::Center => box_l + (box_r - box_l - line_w) / 2.0, + Align::Distributed if n <= 1 => box_l + slack / 2.0, + // Justified lines that aren't stretched (the last one) sit at the start edge. + Align::Justify | Align::JustLow | Align::Distributed if rtl => box_r - line_w, + Align::Justify | Align::JustLow | Align::Distributed => box_l, + } +} + +/// Visual boxes of each cell and the caret x for every boundary, from the visual order and widths. +fn edges_and_carets(order: &[usize], levels: &[u8], adv: &[f64], left: f64, rtl: bool, line_w: f64) -> (Vec<(f64, f64)>, Vec) { + let n = adv.len(); + let mut edges = vec![(left, left); n]; + let mut vx = left; + for &k in order { + let a = adv.get(k).copied().unwrap_or(0.0); + if let Some(e) = edges.get_mut(k) { + *e = (vx, vx + a); + } + vx += a; + } + let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1); + let mut caret_x: Vec = (0..n).map(|k| edges.get(k).map(|e| if odd(k) { e.1 } else { e.0 }).unwrap_or(left)).collect(); + caret_x.push(match n.checked_sub(1) { + Some(k) => edges.get(k).map(|e| if odd(k) { e.0 } else { e.1 }).unwrap_or(left), + None if rtl => left + line_w, + None => left, + }); + (edges, caret_x) +} + +/// Bullet text, x and baseline for a paragraph's first line. +fn line_bullet(env: &ParaEnv, align: Align, content_shift: f64, baseline: f64) -> Option<(String, f64, f64)> { + let p = env.p; + p.bullet.as_ref().map(|(s, _)| { + // Centred and end-aligned text carries its bullet along. + let end_aligned = if p.rtl { align == Align::Left } else { align == Align::Right }; + let shifted = align == Align::Center || end_aligned; + let b = if shifted && p.cells.is_empty() { + content_shift + env.first_x - env.bullet_w + } else { + (env.margin + env.indent).max(0.0) + if shifted { content_shift } else { 0.0 } + }; + // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right. + let bx = if p.rtl { env.g.col_w - b - env.bullet_w } else { b }; + (s.clone(), bx, baseline) + }) +} + +/// Lays out line `li` (`rl`) of a paragraph at the flow's current height and advances the flow. +fn place_line(env: &ParaEnv, pi: usize, rl: &RawLine, li: usize, is_last: bool, flow: &mut Flow) -> PlacedLine { + let (p, g) = (env.p, env.g); + let props = &p.props; + let (asc, desc, size) = line_extent(p, rl); + let natural = (asc + desc).max(size * 1.0); + let lh = match props.line_spacing { + // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint + // applies `lnSpcReduction`. + Some(Spacing::Pct(f)) => natural * (f - env.reduce).max(f * 0.1).max(0.0), + Some(Spacing::Pts(v)) => v * (1.0 - env.reduce), + None => natural * (1.0 - env.reduce), + }; + let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; + let top = flow.y; + // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the + // line pitch, and the text ends at the last line's descent rather than a full pitch. + let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); + let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; + flow.tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; + flow.y += lh; + let x0 = env.start_x(li); + let avail = (g.col_w - x0 - env.mr).max(0.0); + let n = rl.end.saturating_sub(rl.start); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let (mut adv, width) = line_advances(env, rl, is_last, x0); + let align = props.align.unwrap_or(Align::Left); + let slack = (avail - width).max(0.0); + let stretch = !is_last && g.wrap && slack > 0.0; + let (kashida, filled) = justify_line(p, rl, &mut adv, align, slack, stretch); + let line_w: f64 = adv.iter().sum(); + // The line box, in column coordinates: the start edge is the left one for LTR and the + // right one (S = W - rIns) for RTL paragraphs. + let (box_l, box_r) = if p.rtl { (env.mr, g.col_w - x0) } else { (x0, g.col_w - env.mr) }; + let left = line_left(align, filled, p.rtl, (box_l, box_r), line_w, slack, n); + // Distance from the box's start edge to the text (bullets move with centred or + // end-aligned text). + let content_shift = if p.rtl { box_r - (left + line_w) } else { left - box_l }; + // Rules L1 + L2: this line's visual order. + let chars: Vec = line_cells.iter().map(|c| c.ch).collect(); + let raw_levels: Vec = line_cells.iter().map(|c| c.level).collect(); + let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl)); + let order = bidi::visual_order(&levels); + let (edges, caret_x) = edges_and_carets(&order, &levels, &adv, left, p.rtl, line_w); + let bullet = if li == 0 { line_bullet(env, align, content_shift, baseline) } else { None }; + let boundary: Vec = line_cells.iter().map(|c| c.boundary).collect(); + PlacedLine { + info: LineInfo { + para: pi, + start: rl.start, + end: rl.end, + top, + baseline, + bottom: flow.y, + caret_x, + column: 0, + rtl: p.rtl, + levels, + edges, + boundary, + extent: (left, left + line_w), + start_offset: x0, + }, + place: LinePlace { order, kashida }, + bullet, + content_w: width + x0 + env.mr, + } +} + +/// Columns: distribute lines by height. +fn assign_columns(lines: &mut [PlacedLine], g: &BoxGeom) { + let col_h = if g.ncols > 1 { g.inner.height() } else { f64::INFINITY }; + let mut col = 0usize; + let mut col_y0 = 0.0; + for pl in lines.iter_mut() { + let li = &mut pl.info; + if g.ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < g.ncols { + col += 1; + col_y0 = li.top; + } + li.column = col; + if col > 0 { + let dy = col_y0; + li.top -= dy; + li.baseline -= dy; + li.bottom -= dy; + } + let cx = g.col_x(col); + if cx != 0.0 { + li.shift_x(cx); + } + } +} + +/// Origin (x, y) of the text block inside the box, from the anchor and `anchorCtr`. +fn text_origin(lines: &[PlacedLine], g: &BoxGeom, bp: &BodyProps, used_h: f64) -> (f64, f64) { + let inner = g.inner; + let dy = match bp.anchor.unwrap_or(Anchor::Top) { + Anchor::Top => 0.0, + Anchor::Middle => (inner.height() - used_h) / 2.0, + // PowerPoint draws justified and distributed anchoring at the bottom. + Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, + }; + // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put + // them, so the origin is set so the block's centre lands on the box's centre — a left-edge + // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink + // and stay out of the block. Overflowing content centres symmetrically past both edges. + // Not applied to vertical text (there the offset would run along the wrong axis) or + // multi-column bodies (centring unimplemented for them). + let centre_block = bp.anchor_ctr.unwrap_or(false) && g.ncols == 1 && matches!(g.vert, TextDir::Horizontal); + let ox = if centre_block && !lines.is_empty() { + let ink = lines.iter().map(|l| &l.info).filter(|li| li.end > li.start); + let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1))); + if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } + } else { + inner.x0 + }; + (ox, inner.y0 + dy) +} + +/// Moves a line from column coordinates into shape-local ones. +fn position_line(line: &mut LineInfo, ox: f64, oy: f64, g: &BoxGeom) { + line.top += oy; + line.baseline += oy; + line.bottom += oy; + line.shift_x(ox); + if g.lines_ltr() { + // The line's place mirrored across the box: the first line ends up on the left. + let d = g.inner.y0 + g.inner.y1 - line.top - line.bottom; + line.top += d; + line.baseline += d; + line.bottom += d; + } +} + +/// Emits the bullet glyph run of a line's paragraph, if the line carries one. `shift_x` is the +/// text block's x origin plus the line's column offset. +fn emit_bullet(out: &mut TextLayout, p: &ParaShaped, pl: &PlacedLine, shift_x: f64, to_empty: bool) { + let line = &pl.info; + let Some((s, bx, _)) = pl.bullet.as_ref() else { return }; + let Some((_, st)) = p.bullet.as_ref() else { return }; + let (_, g) = measure_str(&st.face, st.size, s); + let run = GlyphRun { + face: st.face.clone(), + size: st.size, + color: st.color, + glyphs: g.into_iter().map(|(gid, x)| (gid, shift_x + bx + x, line.baseline)).collect(), + cells: vec![], + fake_bold: false, + fake_italic: false, + outline: None, + para: line.para, + chars: (0, 0), + link: false, + alpha: st.alpha, + gradient: None, + effects: None, + upright: false, + }; + if to_empty { + out.empty_bullets.push(run); + } else { + out.runs.push(run); + } +} + +/// Position of one cell on its line: index in the line, in the paragraph, its box, and the glyph +/// baseline and size. +struct CellPos { + k: usize, + ci: usize, + cx: f64, + cx1: f64, + base_y: f64, + size: f64, +} + +/// Starts a glyph run for `cell` drawn in style `st`. +fn new_run(cell: &Cell, st: &Style, face: &Arc, para: usize, pos: &CellPos) -> GlyphRun { + GlyphRun { + face: face.clone(), + size: pos.size, + color: st.color, + glyphs: vec![], + cells: vec![], + fake_bold: st.fake_bold, + fake_italic: st.fake_italic, + outline: st.outline, + para, + chars: (pos.ci, pos.ci + 1), + link: st.link, + alpha: st.alpha, + gradient: st.gradient.clone(), + effects: st.effects, + upright: cell.upright, + } +} + +/// Emits one line's glyph runs and decorations, in visual order. +fn emit_line_runs(out: &mut TextLayout, p: &ParaShaped, line: &LineInfo, place: &LinePlace) { + // Group visually consecutive cells by (style, face). + let mut cur: Option = None; + let mut cur_key = (usize::MAX, usize::MAX, false, false); + for &k in &place.order { + let ci = line.start + k; + let Some(cell) = p.cells.get(ci) else { continue }; + let Some(st) = p.styles.get(cell.style) else { continue }; + let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; + let base_y = line.baseline - st.baseline * st.size; + let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; + let small = st.caps == Caps::Small && cell.ch.is_lowercase(); + let size = if small { size * 0.8 } else { size }; + let pos = CellPos { k, ci, cx, cx1, base_y, size }; + // Small capitals are drawn smaller than the capitals beside them: a run of their own. + let key = (cell.style, cell.face, small, cell.upright); + if key != cur_key { + if let Some(r) = cur.take() { + out.runs.push(r); + } + let Some(face) = p.faces.get(cell.face) else { continue }; + cur = Some(new_run(cell, st, face, line.para, &pos)); + cur_key = key; + } + if let Some(r) = cur.as_mut() { + push_cell_glyphs(r, p, line, place, cell, &pos); + } + if cx1 - cx > 0.0 { + push_cell_decos(out, cell, st, line, &pos); + } + } + if let Some(r) = cur.take() { + out.runs.push(r); + } +} + +/// Appends the glyphs a cell draws to the run `r`. +fn push_cell_glyphs(r: &mut GlyphRun, p: &ParaShaped, line: &LineInfo, place: &LinePlace, cell: &Cell, pos: &CellPos) { + let CellPos { k, ci, cx, cx1, base_y, .. } = *pos; + r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); + if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { + // The soft hyphen a line breaks at is drawn as a hyphen. + if let Some(f) = p.faces.get(cell.face) { + r.glyphs.push((f.glyph_for('-'), cx, base_y)); + r.cells.push((ci, cell.ch)); + } + } else if cell.head == ci && !cell.glyphs.is_empty() { + // The cluster's glyphs start at its leftmost cell; kashida (right-to-left + // only) fills the gap on the cluster's left with tatweel glyphs. + let cells_of = (k..line.end.saturating_sub(line.start)).take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci)); + let (mut cl, mut kash) = (cx, 0.0); + for j in cells_of { + cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx)); + kash += place.kashida.get(j).copied().unwrap_or(0.0); + } + if kash > 0.0 { + push_tatweel(r, p.faces.get(cell.face), cell, (cl, kash), pos); + } + for (gid, gx, gy) in &cell.glyphs { + r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); + r.cells.push((ci, cell.ch)); + } + } +} + +/// Fills `kash` points left of a cluster at `cl` with tatweel glyphs. +fn push_tatweel(r: &mut GlyphRun, face: Option<&Arc>, cell: &Cell, fill: (f64, f64), pos: &CellPos) { + let (cl, kash) = fill; + let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0); + let tw = face.map(|f| f.advance(tg) * pos.size / f.upem.max(1.0)).unwrap_or(0.0); + if tg != 0 && tw > 0.0 { + let count = ((kash / tw).ceil() as usize).clamp(1, 256); + for i in 0..count { + r.glyphs.push((tg, cl + (i as f64 * tw).min((kash - tw).max(0.0)), pos.base_y)); + r.cells.push((pos.ci, cell.ch)); + } + } +} + +/// Highlight, underline and strikethrough boxes of a cell. +fn push_cell_decos(out: &mut TextLayout, cell: &Cell, st: &Style, line: &LineInfo, pos: &CellPos) { + let (cx, cx1) = (pos.cx, pos.cx1); + if let Some(hl) = st.highlight { + out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para, line: DecoLine::Solid }); + } + if cell.ch.is_whitespace() && st.underline.is_none() { + return; + } + if let Some((uc, u)) = st.underline { + // At the underline position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); + for (y0, y1, kind) in u.boxes(p, t) { + out.decos.push(Deco { + rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), + color: uc, + behind: false, + para: line.para, + line: kind, + }); + } + } + if let Some(sk) = st.strike { + // At the strikeout position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let t = (st.face.strikeout.1 * k).max(0.5); + let sy = line.baseline - st.face.strikeout.0 * k; + out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para, line: DecoLine::Solid }); + if sk == Strike::Double { + out.decos.push(Deco { + rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); + } + } +} + fn merge_decos(v: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(v.len()); for d in v { diff --git a/crates/text/src/run.rs b/crates/text/src/run.rs index 5b5cb963..d7e9dbee 100644 --- a/crates/text/src/run.rs +++ b/crates/text/src/run.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, FontFace, Point, Rect, Rgba}; /// A run of glyphs in one face, size and colour. #[derive(Clone)] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 11706e6f..03dc8418 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -2,7 +2,10 @@ use super::autonum::autonum; use super::run::{RunEffects, TextShadow}; use super::style::{Cell, Opts, ParaShaped, Style, face_chain, make_style}; use super::symbol::{covers_symbol, map_symbol_bullet, symbol_font_char, symbol_map_char}; -use super::*; +use super::{ + Arc, Bullet, Caps, Ctx, Fill, FontDb, FontFace, FontSlot, HashMap, ParaProps, RunKind, RunProps, ScriptTag, Shape, ShapeParams, TextBody, + UnicodeSegmentation, bidi, resolve, script, +}; #[allow(clippy::too_many_arguments)] pub(crate) fn shape_para( diff --git a/crates/text/src/style.rs b/crates/text/src/style.rs index be8d7e7b..e1289c52 100644 --- a/crates/text/src/style.rs +++ b/crates/text/src/style.rs @@ -1,6 +1,6 @@ use super::run::{RunEffects, Underline}; use super::shape::run_effects; -use super::*; +use super::{Arc, Caps, Ctx, Fill, FontDb, FontFace, FontSlot, ParaProps, Rect, Rgba, RunProps, ScriptTag, Spacing, Strike, resolve}; /// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text. pub trait Fields { diff --git a/crates/text/src/symbol.rs b/crates/text/src/symbol.rs index 214470a6..e79894c3 100644 --- a/crates/text/src/symbol.rs +++ b/crates/text/src/symbol.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{FontFace, UnicodeSegmentation}; /// Is `family` a symbol font (characters at U+F000 + code)? pub(crate) fn is_symbol_font(family: &str) -> bool {