1794 lines
74 KiB
Rust
1794 lines
74 KiB
Rust
//! Text layout for slide text bodies.
|
||
//!
|
||
//! [`layout`] resolves every run's formatting through the placeholder/style chain and lays each
|
||
//! paragraph out in the order bidirectional text needs:
|
||
//! 1. embedding levels for the whole logical paragraph (UAX #9, base level from `a:pPr/@rtl`);
|
||
//! 2. itemization by level, script, face (per-cluster fallback through the `a:latin`/`a:ea`/`a:cs`
|
||
//! slots and the theme's script fonts) and shaping style, each item shaped with the whole
|
||
//! paragraph as context; paint-only attributes (colour, highlight, underline) never split a
|
||
//! word, so Arabic joining and ligatures survive them;
|
||
//! 3. line breaking in logical order (UAX #14 opportunities at cluster boundaries, greedy fill);
|
||
//! 4. per line, rules L1/L2 reorder the characters visually (L4 mirroring is done by the shaper).
|
||
//!
|
||
//! It then places bullets and numbers from the paragraph's start edge (the right edge for RTL
|
||
//! paragraphs), applies indents, spacing and alignment (kashida for `justLow`), anchors the block
|
||
//! in the shape's text box, and shrinks text on overflow when the body asks for it. The result is
|
||
//! positioned glyph runs in shape-local coordinates plus per-line caret geometry for editing:
|
||
//! bidirectional carets with affinity, logical and visual movement, and selection as the union of
|
||
//! visual boxes.
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
mod bidi;
|
||
pub mod datetime;
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
|
||
use deckcraft_color::Rgba;
|
||
use deckcraft_fonts::script::{self, joins_following, joins_preceding};
|
||
use deckcraft_fonts::{FontDb, FontFace, FontSlot, ScriptTag, ShapeParams};
|
||
use deckcraft_geom::{Point, Rect};
|
||
use deckcraft_model::resolve::{self, Ctx};
|
||
use deckcraft_model::text::{Align, Anchor, AutoFit, BodyProps, Bullet, Caps, ParaProps, RunKind, RunProps, Spacing, Strike, TextBody, TextDir};
|
||
use deckcraft_model::{Fill, Shape};
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
|
||
/// A run of glyphs in one face, size and colour.
|
||
#[derive(Clone)]
|
||
pub struct GlyphRun {
|
||
pub face: Arc<FontFace>,
|
||
/// Font size in points (after autofit scaling).
|
||
pub size: f64,
|
||
pub color: Rgba,
|
||
/// Glyph id and pen position of its origin (baseline), in shape-local points.
|
||
pub glyphs: Vec<(u32, f64, f64)>,
|
||
/// Per glyph: the paragraph character it draws (index, char); empty for bullets.
|
||
pub cells: Vec<(usize, char)>,
|
||
/// Draw a heavier stroke because the family has no bold face.
|
||
pub fake_bold: bool,
|
||
/// Slant because the family has no italic face.
|
||
pub fake_italic: bool,
|
||
pub outline: Option<(Rgba, f64)>,
|
||
/// Paragraph and character range this run covers (for hyperlinks, selection colours).
|
||
pub para: usize,
|
||
pub chars: (usize, usize),
|
||
pub link: bool,
|
||
/// Alpha of the fill (0–1).
|
||
pub alpha: f64,
|
||
pub gradient: Option<deckcraft_model::style::Gradient>,
|
||
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
|
||
pub effects: Option<RunEffects>,
|
||
}
|
||
|
||
/// A text run's shadow and glow, colours resolved.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct RunEffects {
|
||
pub shadow: Option<TextShadow>,
|
||
/// Glow colour (with its alpha) and radius in points.
|
||
pub glow: Option<(Rgba, f64)>,
|
||
}
|
||
|
||
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct TextShadow {
|
||
pub color: Rgba,
|
||
pub blur: f64,
|
||
pub dx: f64,
|
||
pub dy: f64,
|
||
}
|
||
|
||
impl std::fmt::Debug for GlyphRun {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("GlyphRun").field("family", &self.face.family).field("size", &self.size).field("glyphs", &self.glyphs.len()).finish()
|
||
}
|
||
}
|
||
|
||
/// An underline, strikethrough or highlight box.
|
||
#[derive(Clone, Debug)]
|
||
pub struct Deco {
|
||
pub rect: Rect,
|
||
pub color: Rgba,
|
||
/// Highlights draw behind text.
|
||
pub behind: bool,
|
||
/// Paragraph the decoration belongs to (by-paragraph animation).
|
||
pub para: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct LineInfo {
|
||
pub para: usize,
|
||
/// Character range [start, end) in the paragraph (end excludes the line's break/newline).
|
||
pub start: usize,
|
||
pub end: usize,
|
||
pub top: f64,
|
||
pub baseline: f64,
|
||
pub bottom: f64,
|
||
/// Caret x for each character boundary start..=end (len = end - start + 1), downstream: the
|
||
/// leading edge of the character after the boundary (its right edge if it is right-to-left);
|
||
/// the last entry is the trailing edge of the line's last character.
|
||
pub caret_x: Vec<f64>,
|
||
/// Column index (multi-column bodies).
|
||
pub column: usize,
|
||
/// Right-to-left paragraph.
|
||
pub rtl: bool,
|
||
/// Bidi level of each character start..end after rule L1 (odd = right-to-left).
|
||
pub levels: Vec<u8>,
|
||
/// Visual box (left, right) of each character start..end. Characters inside a grapheme have
|
||
/// empty boxes; a ligature's characters share its width (GDEF ligature carets).
|
||
pub edges: Vec<(f64, f64)>,
|
||
/// Whether a caret may stand before each character start..end (grapheme boundaries).
|
||
pub boundary: Vec<bool>,
|
||
/// Visual extent (left, right) of the line's text.
|
||
pub extent: (f64, f64),
|
||
/// Distance from the paragraph's start edge to the line's text (indent, margin, bullet).
|
||
pub start_offset: f64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct TextLayout {
|
||
pub runs: Vec<GlyphRun>,
|
||
pub decos: Vec<Deco>,
|
||
pub lines: Vec<LineInfo>,
|
||
/// Inner text box (insets applied), shape-local.
|
||
pub inner: Rect,
|
||
/// Height of the laid-out text (before anchoring).
|
||
pub content_height: f64,
|
||
/// Widest line.
|
||
pub content_width: f64,
|
||
/// Autofit font scale used (1 = none).
|
||
pub font_scale: f64,
|
||
pub line_reduction: f64,
|
||
pub overflow: bool,
|
||
/// Text rotation about the shape centre, degrees (vertical text = 90/270).
|
||
pub rotation: f64,
|
||
/// Per paragraph: (first line index, line count).
|
||
pub para_lines: Vec<(usize, usize)>,
|
||
/// Per paragraph: whether a caret may stand at each character offset 0..=len (grapheme
|
||
/// boundaries), for logical caret movement.
|
||
pub boundaries: Vec<Vec<bool>>,
|
||
}
|
||
|
||
/// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text.
|
||
pub trait Fields {
|
||
fn field(&self, kind: &str) -> Option<String>;
|
||
/// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are
|
||
/// written the language's way.
|
||
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
|
||
let _ = lang;
|
||
self.field(kind)
|
||
}
|
||
}
|
||
|
||
pub struct NoFields;
|
||
impl Fields for NoFields {
|
||
fn field(&self, kind: &str) -> Option<String> {
|
||
if kind == "slidenum" { Some("1".into()) } else { None }
|
||
}
|
||
}
|
||
|
||
pub struct Opts<'a> {
|
||
/// The text box: the preset's text rectangle in shape-local space.
|
||
pub rect: Rect,
|
||
pub fields: &'a dyn Fields,
|
||
/// Draw this text instead of the body (placeholder prompts), in this colour.
|
||
pub prompt_color: Option<Rgba>,
|
||
/// Ignore autofit shrinking (used to measure natural height).
|
||
pub no_shrink: bool,
|
||
}
|
||
|
||
struct Style {
|
||
face: Arc<FontFace>,
|
||
size: f64,
|
||
color: Rgba,
|
||
alpha: f64,
|
||
fake_bold: bool,
|
||
fake_italic: bool,
|
||
baseline: f64,
|
||
spacing: f64,
|
||
caps: Caps,
|
||
underline: Option<Rgba>,
|
||
strike: Option<Strike>,
|
||
highlight: Option<Rgba>,
|
||
outline: Option<(Rgba, f64)>,
|
||
link: bool,
|
||
gradient: Option<deckcraft_model::style::Gradient>,
|
||
effects: Option<RunEffects>,
|
||
/// "Regular", "Bold"… for the slot and fallback faces.
|
||
style_name: &'static str,
|
||
/// BCP 47 language (`a:rPr/@lang`), for shaping.
|
||
lang: Option<String>,
|
||
/// The `a:ea` and `a:cs` families (theme references resolved), if the document names one.
|
||
ea: Option<String>,
|
||
cs: Option<String>,
|
||
/// Heading font (`+mj-…`): the theme's major font set applies.
|
||
major: bool,
|
||
}
|
||
|
||
/// One character of a paragraph after shaping.
|
||
#[derive(Clone)]
|
||
struct Cell {
|
||
ch: char,
|
||
style: usize,
|
||
face: usize,
|
||
/// Resolved bidi embedding level (before the line rules).
|
||
level: u8,
|
||
/// Glyphs of the shaping cluster this cell heads, from the cluster's visual left edge (gid,
|
||
/// x, y). Empty for the other cells of a cluster.
|
||
glyphs: Vec<(u32, f64, f64)>,
|
||
/// This character's advance: its share of the cluster (ligature carets); 0 inside a grapheme.
|
||
adv: f64,
|
||
/// Index of the cell heading this character's shaping cluster.
|
||
head: usize,
|
||
/// A grapheme starts here: carets stand and lines break only before such cells.
|
||
boundary: bool,
|
||
}
|
||
|
||
impl Cell {
|
||
/// A line may start before this cell: a grapheme that starts a shaping cluster.
|
||
fn breakable(&self, i: usize) -> bool {
|
||
self.boundary && self.head == i
|
||
}
|
||
}
|
||
|
||
struct ParaShaped {
|
||
cells: Vec<Cell>,
|
||
styles: Vec<Style>,
|
||
faces: Vec<Arc<FontFace>>,
|
||
props: ParaProps,
|
||
/// Size of the paragraph's first run (spacing percentages, empty lines).
|
||
lead_size: f64,
|
||
lead_face: Arc<FontFace>,
|
||
bullet: Option<(String, Style)>,
|
||
#[allow(dead_code)]
|
||
level: u8,
|
||
/// Right-to-left paragraph (`a:pPr/@rtl`).
|
||
rtl: bool,
|
||
}
|
||
|
||
fn style_name(bold: bool, italic: bool) -> &'static str {
|
||
match (bold, italic) {
|
||
(true, true) => "Bold Italic",
|
||
(true, false) => "Bold",
|
||
(false, true) => "Italic",
|
||
(false, false) => "Regular",
|
||
}
|
||
}
|
||
|
||
fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Style {
|
||
let family = resolve::font_family(ctx, r);
|
||
let bold = r.bold.unwrap_or(false);
|
||
let italic = r.italic.unwrap_or(false);
|
||
let face = FontDb::global().face(&family, style_name(bold, italic));
|
||
let fake_bold = bold && face.weight < 550.0;
|
||
let fake_italic = italic && !face.italic;
|
||
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
|
||
let (mut color, alpha, gradient) = match &r.fill {
|
||
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
|
||
Some(Fill::Gradient(g)) => {
|
||
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
|
||
let mut g = g.clone();
|
||
for s in &mut g.stops {
|
||
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
|
||
}
|
||
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
|
||
}
|
||
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
|
||
_ => (resolve::text_color(ctx, r), 1.0, None),
|
||
};
|
||
if let Some(p) = prompt {
|
||
color = p;
|
||
}
|
||
let major = r.font.as_deref().is_some_and(|f| f.starts_with("+mj"));
|
||
let theme = &ctx.master.theme;
|
||
let slot_family = |explicit: &Option<String>, theme_ref: &str| {
|
||
let name = theme.font(explicit.as_deref().unwrap_or(theme_ref));
|
||
(!name.trim().is_empty()).then_some(name)
|
||
};
|
||
let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" });
|
||
let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" });
|
||
let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color));
|
||
let outline = r.outline.as_ref().and_then(|l| match &l.fill {
|
||
Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))),
|
||
_ => None,
|
||
});
|
||
Style {
|
||
face,
|
||
size,
|
||
color,
|
||
alpha,
|
||
fake_bold,
|
||
fake_italic,
|
||
baseline: r.baseline.unwrap_or(0.0),
|
||
spacing: r.spacing.unwrap_or(0.0) * scale,
|
||
caps: r.caps.unwrap_or(Caps::None),
|
||
underline,
|
||
strike: r.strike.filter(|s| *s != Strike::None),
|
||
highlight: r.highlight.as_ref().map(|c| ctx.color(c, None)),
|
||
outline,
|
||
link: r.link.is_some(),
|
||
gradient,
|
||
effects: run_effects(ctx, r, scale),
|
||
style_name: style_name(bold, italic),
|
||
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
||
ea,
|
||
cs,
|
||
major,
|
||
}
|
||
}
|
||
|
||
/// The faces to try for a cluster of `slot`/`script` in style `st`, in order: the slot's
|
||
/// typeface, the theme's font for the script (`a:font[@script]`), then the run's Latin face.
|
||
fn face_chain(ctx: &Ctx, st: &Style, slot: FontSlot, script: Option<ScriptTag>) -> Vec<Arc<FontFace>> {
|
||
let db = FontDb::global();
|
||
let mut chain: Vec<Arc<FontFace>> = Vec::with_capacity(3);
|
||
let mut push = |family: &str| {
|
||
// Only families that exist: `FontDb::face` would substitute the default face.
|
||
if db.has_family(family) {
|
||
let f = db.face(family, st.style_name);
|
||
if !chain.iter().any(|c| Arc::ptr_eq(c, &f)) {
|
||
chain.push(f);
|
||
}
|
||
}
|
||
};
|
||
match slot {
|
||
FontSlot::EastAsian => st.ea.as_deref().into_iter().for_each(&mut push),
|
||
FontSlot::ComplexScript => st.cs.as_deref().into_iter().for_each(&mut push),
|
||
FontSlot::Latin => {}
|
||
}
|
||
if let Some(sc) = script.and_then(|t| std::str::from_utf8(&t).ok().map(String::from)) {
|
||
let set = if st.major { &ctx.master.theme.fonts.major } else { &ctx.master.theme.fonts.minor };
|
||
if let Some(f) = set.script(&sc) {
|
||
push(f);
|
||
}
|
||
}
|
||
if !chain.iter().any(|c| Arc::ptr_eq(c, &st.face)) {
|
||
chain.push(st.face.clone());
|
||
}
|
||
chain
|
||
}
|
||
|
||
fn spacing_pts(s: Option<Spacing>, size: f64, reduce: f64) -> f64 {
|
||
match s {
|
||
Some(Spacing::Pct(p)) => p * size * 1.2 * (1.0 - reduce),
|
||
Some(Spacing::Pts(v)) => v * (1.0 - reduce),
|
||
None => 0.0,
|
||
}
|
||
}
|
||
|
||
fn roman(mut n: u32, upper: bool) -> String {
|
||
let table = [
|
||
(1000, "m"),
|
||
(900, "cm"),
|
||
(500, "d"),
|
||
(400, "cd"),
|
||
(100, "c"),
|
||
(90, "xc"),
|
||
(50, "l"),
|
||
(40, "xl"),
|
||
(10, "x"),
|
||
(9, "ix"),
|
||
(5, "v"),
|
||
(4, "iv"),
|
||
(1, "i"),
|
||
];
|
||
let mut s = String::new();
|
||
if n == 0 || n > 3999 {
|
||
return n.to_string();
|
||
}
|
||
for (v, r) in table {
|
||
while n >= v {
|
||
s.push_str(r);
|
||
n -= v;
|
||
}
|
||
}
|
||
if upper { s.to_uppercase() } else { s }
|
||
}
|
||
|
||
fn alpha_num(n: u32, upper: bool) -> String {
|
||
if n == 0 {
|
||
return String::new();
|
||
}
|
||
let mut n = n - 1;
|
||
let c = (b'a' + (n % 26) as u8) as char;
|
||
let reps = n / 26 + 1;
|
||
n = reps;
|
||
let s: String = std::iter::repeat_n(c, n as usize).collect();
|
||
if upper { s.to_uppercase() } else { s }
|
||
}
|
||
|
||
/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.".
|
||
pub fn autonum(scheme: &str, n: u32) -> String {
|
||
let (core, upper) = if scheme.starts_with("romanUc") {
|
||
(roman(n, true), true)
|
||
} else if scheme.starts_with("romanLc") {
|
||
(roman(n, false), false)
|
||
} else if scheme.starts_with("alphaUc") {
|
||
(alpha_num(n, true), true)
|
||
} else if scheme.starts_with("alphaLc") {
|
||
(alpha_num(n, false), false)
|
||
} else if scheme.starts_with("circleNum") {
|
||
let c = char::from_u32(0x2460 + n.saturating_sub(1).min(19)).unwrap_or('•');
|
||
return c.to_string();
|
||
} else {
|
||
(n.to_string(), false)
|
||
};
|
||
let _ = upper;
|
||
if scheme.ends_with("ParenBoth") {
|
||
format!("({core})")
|
||
} else if scheme.ends_with("ParenR") {
|
||
format!("{core})")
|
||
} else if scheme.ends_with("Period") {
|
||
format!("{core}.")
|
||
} else if scheme.ends_with("Minus") {
|
||
format!("- {core} -")
|
||
} else {
|
||
core
|
||
}
|
||
}
|
||
|
||
fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>) -> ParaShaped {
|
||
let db = FontDb::global();
|
||
let Some(para) = body.paragraphs.get(pi) else {
|
||
let face = db.face("Inter", "Regular");
|
||
return ParaShaped {
|
||
cells: vec![],
|
||
styles: vec![],
|
||
faces: vec![],
|
||
props: ParaProps::default(),
|
||
lead_size: 18.0,
|
||
lead_face: face,
|
||
bullet: None,
|
||
level: 0,
|
||
rtl: false,
|
||
};
|
||
};
|
||
let props = resolve::para(ctx, shape, para);
|
||
let rtl = props.rtl.unwrap_or(false);
|
||
let mut styles: Vec<Style> = vec![];
|
||
let mut faces: Vec<Arc<FontFace>> = vec![];
|
||
// The logical paragraph: its text and each character's style.
|
||
let mut text = String::new();
|
||
let mut char_style: Vec<usize> = vec![];
|
||
for run in ¶.runs {
|
||
let rp = resolve::run(ctx, shape, para, &run.props);
|
||
let st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||
let t: String = match &run.kind {
|
||
RunKind::Text => run.text.clone(),
|
||
RunKind::Break => "\u{b}".into(),
|
||
RunKind::Field { field } => opts.fields.field_in(field, rp.lang.as_deref()).unwrap_or_else(|| run.text.clone()),
|
||
RunKind::Math { .. } => run.text.clone(),
|
||
};
|
||
let si = styles.len();
|
||
styles.push(st);
|
||
char_style.extend(std::iter::repeat_n(si, t.chars().count()));
|
||
text.push_str(&t);
|
||
}
|
||
let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl);
|
||
let lead = para.runs.first().map(|r| resolve::run(ctx, shape, para, &r.props)).unwrap_or_else(|| resolve::run(ctx, shape, para, ¶.end_props));
|
||
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
|
||
let lead_size = lead_style.size;
|
||
let lead_face = lead_style.face.clone();
|
||
// Bullet.
|
||
let bullet = if para.is_empty() && opts.prompt_color.is_none() {
|
||
None
|
||
} else {
|
||
match &props.bullet {
|
||
Some(Bullet::Char { char }) => {
|
||
let mut rp = lead.clone();
|
||
if let Some(f) = &props.bullet_font {
|
||
rp.font = Some(f.clone());
|
||
}
|
||
rp.bold = Some(false);
|
||
rp.italic = Some(false);
|
||
if let Some(c) = &props.bullet_color {
|
||
rp.fill = Some(Fill::solid(c.clone()));
|
||
}
|
||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||
rp.underline = None;
|
||
rp.strike = None;
|
||
rp.highlight = None;
|
||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||
st.baseline = 0.0;
|
||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||
st.face = db.face("Inter", "Regular");
|
||
}
|
||
Some((ch, st))
|
||
}
|
||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||
let mut rp = lead.clone();
|
||
if let Some(c) = &props.bullet_color {
|
||
rp.fill = Some(Fill::solid(c.clone()));
|
||
}
|
||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||
rp.underline = None;
|
||
let st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
|
||
}
|
||
_ => None,
|
||
}
|
||
};
|
||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||
}
|
||
|
||
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
|
||
/// script, face and shaping style, each shaped with the whole paragraph as context.
|
||
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
|
||
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
|
||
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
|
||
let a = s.dir.to_radians();
|
||
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
|
||
});
|
||
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
|
||
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
|
||
}
|
||
|
||
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
|
||
let db = FontDb::global();
|
||
let chars: Vec<char> = text.chars().collect();
|
||
let n = chars.len();
|
||
// Byte offset of each character (and of the end).
|
||
let mut byte_of: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
|
||
byte_of.push(text.len());
|
||
let char_at_byte: HashMap<usize, usize> = byte_of.iter().enumerate().map(|(i, b)| (*b, i)).collect();
|
||
let levels = bidi::levels(text, &chars, rtl);
|
||
let scripts = script::resolve(&chars);
|
||
let mut boundary = vec![false; n];
|
||
let mut graphemes: Vec<(usize, usize)> = Vec::new();
|
||
for (b, g) in text.grapheme_indices(true) {
|
||
let Some(&i) = char_at_byte.get(&b) else { continue };
|
||
if let Some(x) = boundary.get_mut(i) {
|
||
*x = true;
|
||
}
|
||
graphemes.push((i, i + g.chars().count()));
|
||
}
|
||
let face_index = |faces: &mut Vec<Arc<FontFace>>, f: &Arc<FontFace>| -> usize {
|
||
if let Some(i) = faces.iter().position(|x| Arc::ptr_eq(x, f)) {
|
||
return i;
|
||
}
|
||
faces.push(f.clone());
|
||
faces.len() - 1
|
||
};
|
||
// A face per grapheme cluster (never split between faces).
|
||
let mut char_face = vec![0usize; n];
|
||
let mut chains: HashMap<(usize, FontSlot, Option<ScriptTag>), Vec<Arc<FontFace>>> = HashMap::new();
|
||
let mut prev: Option<Arc<FontFace>> = None;
|
||
for &(a, b) in &graphemes {
|
||
let Some(cluster) = chars.get(a..b) else { continue };
|
||
let si = char_style.get(a).copied().unwrap_or(0);
|
||
let Some(st) = styles.get(si) else { continue };
|
||
let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
|
||
// Spaces, digits and punctuation stay in the face of the text before them when it can
|
||
// draw them, so a phrase isn't cut into items at every space.
|
||
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
|
||
let neutral =
|
||
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
|
||
let face = match prev.as_ref() {
|
||
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||
_ => {
|
||
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
|
||
db.cascade(cluster, chain, st.style_name)
|
||
}
|
||
};
|
||
let fi = face_index(faces, &face);
|
||
for f in char_face.get_mut(a..b).into_iter().flatten() {
|
||
*f = fi;
|
||
}
|
||
prev = Some(face);
|
||
}
|
||
let mut cells: Vec<Cell> = (0..n)
|
||
.map(|i| Cell {
|
||
ch: chars.get(i).copied().unwrap_or(' '),
|
||
style: char_style.get(i).copied().unwrap_or(0),
|
||
face: char_face.get(i).copied().unwrap_or(0),
|
||
level: levels.get(i).copied().unwrap_or(0),
|
||
glyphs: vec![],
|
||
adv: 0.0,
|
||
head: i,
|
||
boundary: boundary.get(i).copied().unwrap_or(true),
|
||
})
|
||
.collect();
|
||
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
|
||
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
|
||
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
|
||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
|
||
let mut a = 0;
|
||
while a < n {
|
||
let mut b = a + 1;
|
||
while b < n && key(b) == key(a) {
|
||
b += 1;
|
||
}
|
||
shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a));
|
||
a = b;
|
||
}
|
||
cells
|
||
}
|
||
|
||
/// Size of superscript and subscript text relative to the run's size.
|
||
const SUB_SUPER: f64 = 0.66;
|
||
|
||
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
|
||
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn shape_item(
|
||
cells: &mut [Cell],
|
||
styles: &[Style],
|
||
faces: &[Arc<FontFace>],
|
||
text: &str,
|
||
byte_of: &[usize],
|
||
char_at_byte: &HashMap<usize, usize>,
|
||
range: std::ops::Range<usize>,
|
||
script: Option<ScriptTag>,
|
||
lang: Option<&str>,
|
||
upper: bool,
|
||
) {
|
||
let Some(first) = cells.get(range.start) else { return };
|
||
let (fi, level) = (first.face, first.level);
|
||
let Some(face) = faces.get(fi) else { return };
|
||
let rtl = level % 2 == 1;
|
||
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
|
||
let params = ShapeParams { features: &[], rtl, script, language: lang };
|
||
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
|
||
let upem = face.upem.max(1.0);
|
||
let scale_of = |cell: &Cell| {
|
||
let st = styles.get(cell.style);
|
||
let size = st.map(|s| s.size).unwrap_or(18.0);
|
||
let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
|
||
let size = if small { size * 0.8 } else { size };
|
||
// Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
|
||
// two thirds too.
|
||
let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size };
|
||
size / upem
|
||
};
|
||
// Cluster heads in this item, ascending; each cluster spans to the next head.
|
||
let mut heads: Vec<usize> = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect();
|
||
heads.sort_unstable();
|
||
heads.dedup();
|
||
let mut width: HashMap<usize, f64> = HashMap::new();
|
||
let mut lig: HashMap<usize, (u32, usize)> = HashMap::new();
|
||
for g in &glyphs {
|
||
let Some(&h) = char_at_byte.get(&g.cluster) else { continue };
|
||
let Some(cell) = cells.get_mut(h) else { continue };
|
||
if matches!(cell.ch, '\t' | '\u{b}' | '\n') {
|
||
continue;
|
||
}
|
||
let s = scale_of(cell);
|
||
let w = width.entry(h).or_insert(0.0);
|
||
cell.glyphs.push((g.gid, *w + g.x_offset as f64 * s, -(g.y_offset as f64) * s));
|
||
*w += g.x_advance as f64 * s;
|
||
let e = lig.entry(h).or_insert((g.gid, 0));
|
||
e.1 += 1;
|
||
}
|
||
for (hi, &h) in heads.iter().enumerate() {
|
||
let end = heads.get(hi + 1).copied().unwrap_or(range.end).min(range.end);
|
||
let w = width.get(&h).copied().unwrap_or(0.0);
|
||
for c in cells.get_mut(h..end).into_iter().flatten() {
|
||
c.head = h;
|
||
}
|
||
let stops: Vec<usize> = (h..end).filter(|&i| i == h || cells.get(i).is_some_and(|c| c.boundary)).collect();
|
||
let m = stops.len().max(1);
|
||
// Visual widths of the cluster's graphemes, left to right.
|
||
let mut bounds = vec![0.0];
|
||
if m > 1
|
||
&& let Some(&(gid, 1)) = lig.get(&h)
|
||
{
|
||
let s = cells.get(h).map(scale_of).unwrap_or(0.0);
|
||
let carets = deckcraft_fonts::ligature_carets(face, gid);
|
||
if carets.len() + 1 >= m {
|
||
bounds.extend(carets.iter().take(m - 1).map(|c| (*c as f64 * s).clamp(0.0, w)));
|
||
}
|
||
}
|
||
if bounds.len() != m {
|
||
bounds = (0..m).map(|j| w * j as f64 / m as f64).collect();
|
||
}
|
||
bounds.push(w);
|
||
for (j, &i) in stops.iter().enumerate() {
|
||
let vj = if rtl { m - 1 - j } else { j };
|
||
let share = bounds.get(vj + 1).copied().unwrap_or(w) - bounds.get(vj).copied().unwrap_or(0.0);
|
||
if let Some(c) = cells.get_mut(i) {
|
||
c.adv = share.max(0.0);
|
||
}
|
||
}
|
||
}
|
||
// Tracking (character spacing) after every drawn grapheme and space.
|
||
for i in range {
|
||
let Some(c) = cells.get(i) else { continue };
|
||
let drawn = cells.get(c.head).is_some_and(|hd| !hd.glyphs.is_empty());
|
||
if c.boundary
|
||
&& (drawn || c.ch == ' ')
|
||
&& let Some(sp) = styles.get(c.style).map(|s| s.spacing)
|
||
&& let Some(c) = cells.get_mut(i)
|
||
{
|
||
c.adv += sp;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||
let first = c.chars().next().unwrap_or('•');
|
||
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
|
||
let code = (first as u32) & 0xff;
|
||
let m = match code {
|
||
0xa7 => '▪',
|
||
0xd8 => '➢',
|
||
0xfc => '✓',
|
||
0x76 => '❖',
|
||
0x71 => '❑',
|
||
0x6e => '■',
|
||
0x6c => '●',
|
||
0xa8 => '◆',
|
||
0xb7 => '•',
|
||
0x2d => '–',
|
||
_ => '•',
|
||
};
|
||
return m.to_string();
|
||
}
|
||
// PowerPoint draws only the first character of a longer `buChar` ("••" draws one dot).
|
||
c.graphemes(true).next().unwrap_or("•").to_string()
|
||
}
|
||
|
||
fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||
let g = deckcraft_fonts::shape(face, s, &[], |c| c);
|
||
let k = size / face.upem.max(1.0);
|
||
let mut x = 0.0;
|
||
let mut out = vec![];
|
||
for gl in g {
|
||
out.push((gl.gid, x + gl.x_offset as f64 * k));
|
||
x += gl.x_advance as f64 * k;
|
||
}
|
||
(x, out)
|
||
}
|
||
|
||
/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2
|
||
/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18
|
||
/// and 40 pt), whatever the font's own ascent and descent.
|
||
///
|
||
/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or
|
||
/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics.
|
||
/// Keep it a single tunable value, and re-measure on the other platform before changing it.
|
||
const LINE_PITCH: f64 = 1.2;
|
||
|
||
/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where
|
||
/// the font's Windows ascent and descent put the baseline.
|
||
fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
|
||
let h = size * LINE_PITCH;
|
||
(h * face.line_split, h * (1.0 - face.line_split))
|
||
}
|
||
|
||
/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
|
||
/// syllables.
|
||
fn is_hangul(c: char) -> bool {
|
||
matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
|
||
}
|
||
|
||
/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
|
||
fn is_hanging_punct(c: char) -> bool {
|
||
matches!(
|
||
c,
|
||
'、' | '。'
|
||
| ','
|
||
| '.'
|
||
| ':'
|
||
| ';'
|
||
| '!'
|
||
| '?'
|
||
| ')'
|
||
| '」'
|
||
| '』'
|
||
| '】'
|
||
| '〕'
|
||
| '〉'
|
||
| '》'
|
||
| '〙'
|
||
| '〛'
|
||
| '}'
|
||
| ']'
|
||
| '」'
|
||
| '、'
|
||
| '。'
|
||
)
|
||
}
|
||
|
||
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
|
||
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
|
||
/// Korean breaks only between words.
|
||
fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
|
||
let n = text.chars().count();
|
||
let mut allowed = vec![false; n + 1];
|
||
let mut mandatory = vec![false; n + 1];
|
||
let mut byte_to_char = std::collections::HashMap::new();
|
||
for (ci, (b, _)) in text.char_indices().enumerate() {
|
||
byte_to_char.insert(b, ci);
|
||
}
|
||
byte_to_char.insert(text.len(), n);
|
||
for (b, op) in unicode_linebreak::linebreaks(text) {
|
||
if let Some(&ci) = byte_to_char.get(&b) {
|
||
if let Some(a) = allowed.get_mut(ci) {
|
||
*a = true;
|
||
}
|
||
if op == unicode_linebreak::BreakOpportunity::Mandatory
|
||
&& let Some(m) = mandatory.get_mut(ci)
|
||
{
|
||
*m = true;
|
||
}
|
||
}
|
||
}
|
||
let chars: Vec<char> = text.chars().collect();
|
||
for (i, w) in chars.windows(2).enumerate() {
|
||
let (prev, next) = (w[0], w[1]);
|
||
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
|
||
if !keep
|
||
&& !mandatory.get(i + 1).copied().unwrap_or(false)
|
||
&& let Some(a) = allowed.get_mut(i + 1)
|
||
{
|
||
*a = false;
|
||
}
|
||
}
|
||
(allowed, mandatory)
|
||
}
|
||
|
||
struct RawLine {
|
||
start: usize,
|
||
end: usize,
|
||
/// End including trailing spaces / the break char consumed.
|
||
next: usize,
|
||
}
|
||
|
||
fn break_lines(
|
||
p: &ParaShaped,
|
||
first_w: f64,
|
||
rest_w: f64,
|
||
wrap: bool,
|
||
tab_stops: &[f64],
|
||
default_tab: f64,
|
||
x0_first: f64,
|
||
x0_rest: f64,
|
||
) -> Vec<RawLine> {
|
||
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);
|
||
let n = p.cells.len();
|
||
let mut lines = vec![];
|
||
let mut start = 0;
|
||
if n == 0 {
|
||
return vec![RawLine { start: 0, end: 0, next: 0 }];
|
||
}
|
||
while start < n {
|
||
let avail = if lines.is_empty() { first_w } else { rest_w };
|
||
let x0 = if lines.is_empty() { x0_first } else { x0_rest };
|
||
let mut x = 0.0;
|
||
let mut last_break: Option<usize> = None;
|
||
let mut i = start;
|
||
let mut end = n;
|
||
let mut next = n;
|
||
while i < n {
|
||
let c = p.cells.get(i).map(|c| c.ch).unwrap_or(' ');
|
||
if c == '\u{b}' || c == '\n' {
|
||
end = i;
|
||
next = i + 1;
|
||
break;
|
||
}
|
||
let adv = if c == '\t' { tab_advance(x0 + x, tab_stops, default_tab) } else { p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) };
|
||
let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j));
|
||
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
|
||
last_break = Some(i);
|
||
}
|
||
// Closing East Asian punctuation hangs past the end instead of wrapping
|
||
// (`hangingPunct`); the line breaks after it.
|
||
if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start {
|
||
end = i + 1;
|
||
next = i + 1;
|
||
break;
|
||
}
|
||
if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
|
||
// Break at the last opportunity, or force mid-word (between clusters).
|
||
let b = last_break
|
||
.filter(|b| *b > start)
|
||
.unwrap_or_else(|| (start + 1..=i).rev().find(|&j| breakable(j)).or_else(|| (i + 1..n).find(|&j| breakable(j))).unwrap_or(n));
|
||
end = b;
|
||
next = b;
|
||
// Trailing spaces belong to the line but don't count.
|
||
while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') {
|
||
end -= 1;
|
||
}
|
||
break;
|
||
}
|
||
x += adv;
|
||
i += 1;
|
||
}
|
||
if i >= n {
|
||
end = n;
|
||
next = n;
|
||
}
|
||
lines.push(RawLine { start, end, next });
|
||
if next <= start {
|
||
// No progress: force one char.
|
||
let last = lines.len() - 1;
|
||
let to = (start + 1..n).find(|&j| p.cells.get(j).is_none_or(|c| c.breakable(j))).unwrap_or(n);
|
||
if let Some(l) = lines.get_mut(last) {
|
||
l.end = to;
|
||
l.next = to;
|
||
}
|
||
start = to;
|
||
} else {
|
||
start = next;
|
||
}
|
||
if next == n && p.cells.last().is_some_and(|c| c.ch == '\u{b}') && start >= n {
|
||
// A trailing line break opens an empty last line.
|
||
lines.push(RawLine { start: n, end: n, next: n });
|
||
}
|
||
}
|
||
lines
|
||
}
|
||
|
||
fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 {
|
||
for s in stops {
|
||
if *s > x + 0.5 {
|
||
return s - x;
|
||
}
|
||
}
|
||
let d = if default_tab > 1.0 { default_tab } else { 72.0 };
|
||
let next = ((x / d).floor() + 1.0) * d;
|
||
(next - x).max(1.0)
|
||
}
|
||
|
||
/// Lay out a shape's text body.
|
||
pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout {
|
||
let bp = resolve::body(ctx, shape);
|
||
let autofit = bp.autofit.unwrap_or(AutoFit::None);
|
||
let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0);
|
||
if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink)
|
||
&& l.overflow
|
||
{
|
||
// Binary search the largest scale that fits (steps like 90%, 80%… with line reduction).
|
||
let (mut lo, mut hi) = (0.1_f64, 1.0_f64);
|
||
let mut best = None;
|
||
for _ in 0..12 {
|
||
let mid = (lo + hi) / 2.0;
|
||
let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2);
|
||
let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red);
|
||
if t.overflow {
|
||
hi = mid;
|
||
} else {
|
||
lo = mid;
|
||
best = Some(t);
|
||
}
|
||
}
|
||
l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2));
|
||
}
|
||
l
|
||
}
|
||
|
||
fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout {
|
||
let r = opts.rect;
|
||
let vert = bp.vert.unwrap_or(TextDir::Horizontal);
|
||
let rotation = match vert {
|
||
TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 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)),
|
||
);
|
||
let wrap = bp.wrap.unwrap_or(true) && !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 mut out = TextLayout { inner, font_scale: scale, line_reduction: reduce, rotation, ..Default::default() };
|
||
let mut y = 0.0;
|
||
let mut numbering: Vec<u32> = vec![0; 9];
|
||
let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![];
|
||
let mut prev_after = 0.0;
|
||
let mut shaped_paras: Vec<ParaShaped> = 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 !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));
|
||
}
|
||
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: Vec<f64> = props.tabs.as_ref().map(|t| t.iter().map(|s| s.pos).collect()).unwrap_or_default();
|
||
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);
|
||
let before = if pi == 0 { 0.0 } else { spacing_pts(props.space_before, p.lead_size, reduce) };
|
||
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;
|
||
let (a, d) = line_metrics(face, sz);
|
||
asc = asc.max(a);
|
||
desc = desc.max(d);
|
||
size = size.max(sz);
|
||
}
|
||
}
|
||
if size == 0.0 {
|
||
let (a, d) = line_metrics(&p.lead_face, p.lead_size);
|
||
asc = a;
|
||
desc = d;
|
||
size = p.lead_size;
|
||
}
|
||
let natural = (asc + desc).max(size * 1.0);
|
||
let lh = match props.line_spacing {
|
||
Some(Spacing::Pct(f)) => natural * f.max(0.0) * (1.0 - reduce),
|
||
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;
|
||
let baseline = top + asc * ratio;
|
||
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;
|
||
for c in line_cells {
|
||
let a = if c.ch == '\t' { tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0)) } 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<char> = line_cells.iter().map(|c| c.ch).collect();
|
||
let raw_levels: Vec<u8> = 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<f64> = (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<bool> = 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, reduce).max(0.0);
|
||
}
|
||
let total = y;
|
||
out.content_height = total;
|
||
// 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;
|
||
li.shift_x(col as f64 * (col_w + col_gap));
|
||
}
|
||
}
|
||
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 | Anchor::Justified | Anchor::Distributed => 0.0,
|
||
Anchor::Middle => (inner.height() - used_h) / 2.0,
|
||
Anchor::Bottom => inner.height() - used_h,
|
||
};
|
||
// anchorCtr: centre the block horizontally.
|
||
let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 };
|
||
let ox = inner.x0 + dx;
|
||
let oy = inner.y0 + dy;
|
||
// 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);
|
||
let Some(p) = shaped_paras.get(line.para) else { continue };
|
||
if let Some((s, bx, by)) = bullet.take()
|
||
&& let Some((_, st)) = p.bullet.as_ref()
|
||
{
|
||
let (_, g) = measure_str(&st.face, st.size, &s);
|
||
out.runs.push(GlyphRun {
|
||
face: st.face.clone(),
|
||
size: st.size,
|
||
color: st.color,
|
||
glyphs: g
|
||
.into_iter()
|
||
.map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy))
|
||
.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,
|
||
});
|
||
}
|
||
// Group visually consecutive cells by (style, face).
|
||
let mut cur: Option<GlyphRun> = None;
|
||
let mut cur_key = (usize::MAX, usize::MAX);
|
||
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 };
|
||
let key = (cell.style, cell.face);
|
||
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,
|
||
});
|
||
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.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 });
|
||
}
|
||
if !cell.ch.is_whitespace() || st.underline.is_some() {
|
||
if let Some(uc) = st.underline {
|
||
let t = (st.size * 0.06).max(0.5);
|
||
let uy = line.baseline + st.size * 0.12;
|
||
out.decos.push(Deco { rect: Rect::new(cx, uy, cx1, uy + t), color: uc, behind: false, para: line.para });
|
||
}
|
||
if let Some(sk) = st.strike {
|
||
let t = (st.size * 0.05).max(0.5);
|
||
let sy = line.baseline - st.size * 0.3;
|
||
out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para });
|
||
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 });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(r) = cur.take() {
|
||
out.runs.push(r);
|
||
}
|
||
}
|
||
out.boundaries = shaped_paras
|
||
.iter()
|
||
.map(|p| {
|
||
let mut b: Vec<bool> = p.cells.iter().map(|c| c.boundary).collect();
|
||
b.push(true);
|
||
if let Some(f) = b.first_mut() {
|
||
*f = true;
|
||
}
|
||
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
|
||
}
|
||
|
||
fn merge_decos(v: Vec<Deco>) -> Vec<Deco> {
|
||
let mut out: Vec<Deco> = Vec::with_capacity(v.len());
|
||
for d in v {
|
||
if let Some(last) = out.last_mut()
|
||
&& last.color == d.color
|
||
&& last.behind == d.behind
|
||
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
|
||
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
|
||
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
|
||
{
|
||
last.rect.x1 = d.rect.x1;
|
||
continue;
|
||
}
|
||
out.push(d);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// How a line's cells are placed: visual order (indices from the line start) and kashida widths
|
||
/// (the boxes, with justification, are in [`LineInfo::edges`]).
|
||
struct LinePlace {
|
||
order: Vec<usize>,
|
||
kashida: Vec<f64>,
|
||
}
|
||
|
||
/// Kashida opportunities on a line (indices from the line start), at most one per word (its last
|
||
/// join, where elongation reads most naturally): the last cell of a right-to-left Arabic grapheme
|
||
/// that joins the next letter, in a face with a tatweel glyph, when the two aren't one ligature.
|
||
fn kashida_opportunities(p: &ParaShaped, start: usize, end: usize) -> Vec<usize> {
|
||
let mut out: Vec<usize> = Vec::new();
|
||
let mut word_has = false;
|
||
for i in start..end {
|
||
let Some(c) = p.cells.get(i) else { continue };
|
||
if c.ch.is_whitespace() {
|
||
word_has = false;
|
||
continue;
|
||
}
|
||
if !c.boundary || c.level % 2 == 0 || !joins_following(c.ch) {
|
||
continue;
|
||
}
|
||
// The next grapheme on the line.
|
||
let Some(nx) = (i + 1..end).find(|&j| p.cells.get(j).is_some_and(|d| d.boundary)) else { continue };
|
||
let Some(d) = p.cells.get(nx) else { continue };
|
||
let same_cluster = d.head == c.head || d.head != nx;
|
||
let tatweel = p.faces.get(c.face).is_some_and(|f| f.glyph_for('\u{0640}') != 0);
|
||
if joins_preceding(d.ch) && d.face == c.face && !same_cluster && tatweel {
|
||
// A later join in the same word replaces the earlier one.
|
||
if word_has {
|
||
out.pop();
|
||
}
|
||
out.push(nx - 1 - start);
|
||
word_has = true;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Which side of a boundary a caret belongs to: `Downstream` sticks to the character after it
|
||
/// (the default), `Upstream` to the character before it, which differs at direction changes and
|
||
/// at the end of a wrapped line.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum Affinity {
|
||
Upstream,
|
||
#[default]
|
||
Downstream,
|
||
}
|
||
|
||
/// Caret movement: through the text order, or across the screen.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum MoveMode {
|
||
/// Next/previous grapheme in logical order (`forward` = towards the end of the text).
|
||
#[default]
|
||
Logical,
|
||
/// Next grapheme boundary to the right/left on screen (`forward` = right).
|
||
Visual,
|
||
}
|
||
|
||
impl LineInfo {
|
||
fn len(&self) -> usize {
|
||
self.end.saturating_sub(self.start)
|
||
}
|
||
fn odd(&self, k: usize) -> bool {
|
||
self.levels.get(k).is_some_and(|l| l % 2 == 1)
|
||
}
|
||
/// Leading edge (where its direction begins) of character `k` of the line.
|
||
fn leading(&self, k: usize) -> Option<f64> {
|
||
self.edges.get(k).map(|e| if self.odd(k) { e.1 } else { e.0 })
|
||
}
|
||
/// Trailing edge (where its direction ends) of character `k` of the line.
|
||
fn trailing(&self, k: usize) -> Option<f64> {
|
||
self.edges.get(k).map(|e| if self.odd(k) { e.0 } else { e.1 })
|
||
}
|
||
fn shift_x(&mut self, dx: f64) {
|
||
for x in &mut self.caret_x {
|
||
*x += dx;
|
||
}
|
||
for e in &mut self.edges {
|
||
e.0 += dx;
|
||
e.1 += dx;
|
||
}
|
||
self.extent.0 += dx;
|
||
self.extent.1 += dx;
|
||
}
|
||
/// Graphemes on the line as (first, end) character indices from the line start.
|
||
fn graphemes(&self) -> Vec<(usize, usize)> {
|
||
let n = self.len();
|
||
let mut out = Vec::new();
|
||
let mut k = 0;
|
||
while k < n {
|
||
let mut e = k + 1;
|
||
while e < n && !self.boundary.get(e).copied().unwrap_or(true) {
|
||
e += 1;
|
||
}
|
||
out.push((k, e));
|
||
k = e;
|
||
}
|
||
out
|
||
}
|
||
/// Visual extent of characters `from..to` (k from the line start).
|
||
fn grapheme_box(&self, from: usize, to: usize) -> Option<(f64, f64)> {
|
||
let es = self.edges.get(from..to)?;
|
||
let l = es.iter().map(|e| e.0).fold(f64::INFINITY, f64::min);
|
||
let r = es.iter().map(|e| e.1).fold(f64::NEG_INFINITY, f64::max);
|
||
(l <= r).then_some((l, r))
|
||
}
|
||
/// Visual x intervals covering paragraph characters `from..to` on this line, left to right
|
||
/// and merged where they touch. Bidi text can make a logical range several boxes.
|
||
pub fn spans(&self, from: usize, to: usize) -> Vec<(f64, f64)> {
|
||
let a = from.max(self.start) - self.start;
|
||
let b = to.min(self.end).saturating_sub(self.start);
|
||
merge_spans(self.edges.get(a..b.max(a)).unwrap_or(&[]).to_vec())
|
||
}
|
||
/// Horizontal extent of paragraph characters `from..to` on this line.
|
||
pub fn span(&self, from: usize, to: usize) -> Option<(f64, f64)> {
|
||
let s = self.spans(from, to);
|
||
Some((s.first()?.0, s.last()?.1))
|
||
}
|
||
/// Caret stops left to right: (x, character offset in the paragraph, affinity).
|
||
fn visual_stops(&self) -> Vec<(f64, usize, Affinity)> {
|
||
let mut gs: Vec<(f64, f64, usize, usize, bool)> =
|
||
self.graphemes().into_iter().filter_map(|(a, b)| self.grapheme_box(a, b).map(|(l, r)| (l, r, a, b, self.odd(a)))).collect();
|
||
gs.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||
let mut out: Vec<(f64, usize, Affinity)> = Vec::with_capacity(gs.len() * 2 + 1);
|
||
let mut push = |s: (f64, usize, Affinity)| {
|
||
if !out.last().is_some_and(|l: &(f64, usize, Affinity)| (l.0 - s.0).abs() < 0.01 && l.1 == s.1) {
|
||
out.push(s);
|
||
}
|
||
};
|
||
for (l, r, a, b, rtl) in gs {
|
||
let (a, b) = (self.start + a, self.start + b);
|
||
if rtl {
|
||
push((l, b, Affinity::Upstream));
|
||
push((r, a, Affinity::Downstream));
|
||
} else {
|
||
push((l, a, Affinity::Downstream));
|
||
push((r, b, Affinity::Upstream));
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
out.push((self.caret_x.first().copied().unwrap_or(0.0), self.start, Affinity::Downstream));
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Non-empty x intervals sorted left to right, merged where they touch.
|
||
fn merge_spans(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
|
||
v.retain(|e| e.1 > e.0);
|
||
v.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||
let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len());
|
||
for e in v {
|
||
match out.last_mut() {
|
||
Some(last) if e.0 <= last.1 + 0.01 => last.1 = last.1.max(e.1),
|
||
_ => out.push(e),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A caret position: paragraph and character offset within it.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||
pub struct Pos {
|
||
pub para: usize,
|
||
pub ch: usize,
|
||
}
|
||
|
||
impl TextLayout {
|
||
/// The line containing a caret position (the later line at a soft wrap boundary).
|
||
pub fn line_of(&self, pos: Pos) -> Option<usize> {
|
||
self.line_of_affine(pos, Affinity::Downstream)
|
||
}
|
||
/// The line containing a caret position: at a soft wrap boundary, the earlier line for an
|
||
/// upstream caret and the later one for a downstream caret.
|
||
pub fn line_of_affine(&self, pos: Pos, affinity: Affinity) -> Option<usize> {
|
||
let mut found = None;
|
||
for (i, l) in self.lines.iter().enumerate() {
|
||
if l.para == pos.para && pos.ch >= l.start && pos.ch <= l.end {
|
||
if affinity == Affinity::Upstream && pos.ch == l.end && pos.ch > l.start {
|
||
return Some(i);
|
||
}
|
||
found = Some(i);
|
||
if pos.ch < l.end {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
found
|
||
.or_else(|| self.lines.iter().rposition(|l| l.para == pos.para && l.start <= pos.ch))
|
||
.or_else(|| self.lines.iter().rposition(|l| l.para == pos.para))
|
||
}
|
||
/// Caret line segment (x, top, bottom) in shape-local, unrotated coordinates (downstream).
|
||
pub fn caret(&self, pos: Pos) -> Option<(f64, f64, f64)> {
|
||
self.caret_at(pos, Affinity::Downstream)
|
||
}
|
||
/// Caret line segment (x, top, bottom) for a position and affinity: downstream at the leading
|
||
/// edge of the character after the boundary, upstream at the trailing edge of the one before.
|
||
pub fn caret_at(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> {
|
||
let li = self.line_of_affine(pos, affinity)?;
|
||
let l = self.lines.get(li)?;
|
||
let k = pos.ch.clamp(l.start, l.end) - l.start;
|
||
let x = match affinity {
|
||
Affinity::Downstream if k < l.len() => l.leading(k),
|
||
Affinity::Upstream if k > 0 => l.trailing(k - 1),
|
||
_ => None,
|
||
};
|
||
let x = x.or_else(|| l.caret_x.get(k).copied()).or(l.caret_x.last().copied())?;
|
||
Some((x, l.top, l.bottom))
|
||
}
|
||
/// Nearest caret position for a point.
|
||
pub fn hit(&self, p: Point) -> Pos {
|
||
self.hit_affinity(p).0
|
||
}
|
||
/// Nearest caret position for a point, with the affinity that keeps the caret on the side of
|
||
/// the character that was hit (bidi text has two carets at a direction change).
|
||
pub fn hit_affinity(&self, p: Point) -> (Pos, Affinity) {
|
||
let mut best: Option<&LineInfo> = None;
|
||
for l in &self.lines {
|
||
if p.y >= l.top && p.y < l.bottom {
|
||
// In multi-column bodies pick the line whose column contains x.
|
||
if best.is_none() || p.x >= l.extent.0.min(l.caret_x.first().copied().unwrap_or(l.extent.0)) - 4.0 {
|
||
best = Some(l);
|
||
}
|
||
}
|
||
}
|
||
let l = match best {
|
||
Some(l) => l,
|
||
None => {
|
||
let first = self.lines.first();
|
||
let last = self.lines.last();
|
||
match (first, last) {
|
||
(Some(f), _) if p.y < f.top => f,
|
||
(_, Some(l)) => l,
|
||
_ => return (Pos::default(), Affinity::Downstream),
|
||
}
|
||
}
|
||
};
|
||
let mut gs: Vec<(f64, f64, usize, usize, bool)> =
|
||
l.graphemes().into_iter().filter_map(|(a, b)| l.grapheme_box(a, b).map(|(x0, x1)| (x0, x1, a, b, l.odd(a)))).collect();
|
||
if gs.is_empty() {
|
||
return (Pos { para: l.para, ch: l.start }, Affinity::Downstream);
|
||
}
|
||
gs.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||
let hit = gs.iter().find(|g| p.x >= g.0 && p.x <= g.1 && g.1 > g.0).copied();
|
||
let (g, left_half) = match hit {
|
||
Some(g) => (g, p.x < (g.0 + g.1) / 2.0),
|
||
None => match (gs.first().copied(), gs.last().copied()) {
|
||
(Some(f), _) if p.x < f.0 => (f, true),
|
||
(_, Some(z)) if p.x > z.1 => (z, false),
|
||
// Between zero-width graphemes: the nearest one.
|
||
_ => match gs.iter().min_by(|a, b| ((a.0 + a.1) / 2.0 - p.x).abs().total_cmp(&((b.0 + b.1) / 2.0 - p.x).abs())).copied() {
|
||
Some(g) => (g, p.x < (g.0 + g.1) / 2.0),
|
||
None => return (Pos { para: l.para, ch: l.start }, Affinity::Downstream),
|
||
},
|
||
},
|
||
};
|
||
let (_, _, a, b, rtl) = g;
|
||
let (before, after) = (Pos { para: l.para, ch: l.start + a }, Pos { para: l.para, ch: l.start + b });
|
||
// The leading half of a character puts the caret before it, the trailing half after it.
|
||
if left_half != rtl { (before, Affinity::Downstream) } else { (after, Affinity::Upstream) }
|
||
}
|
||
/// Move a caret one grapheme: logically (`forward` = towards the end of the text, crossing
|
||
/// paragraphs) or visually (`forward` = to the right on screen, continuing on the next or
|
||
/// previous line in the paragraph's direction at the line's edge).
|
||
pub fn move_caret(&self, pos: Pos, affinity: Affinity, forward: bool, mode: MoveMode) -> (Pos, Affinity) {
|
||
if mode == MoveMode::Visual
|
||
&& let Some(li) = self.line_of_affine(pos, affinity)
|
||
&& let Some(l) = self.lines.get(li)
|
||
{
|
||
let stops = l.visual_stops();
|
||
let cur_x = self.caret_at(pos, affinity).map(|c| c.0).unwrap_or(0.0);
|
||
let idx = stops
|
||
.iter()
|
||
.position(|s| s.1 == pos.ch && s.2 == affinity)
|
||
.or_else(|| stops.iter().position(|s| s.1 == pos.ch))
|
||
.or_else(|| stops.iter().enumerate().min_by(|a, b| (a.1.0 - cur_x).abs().total_cmp(&(b.1.0 - cur_x).abs())).map(|(i, _)| i));
|
||
if let Some(idx) = idx {
|
||
let mut j = idx;
|
||
loop {
|
||
let next = if forward { j.checked_add(1) } else { j.checked_sub(1) };
|
||
let Some(nj) = next else { break };
|
||
let Some(s) = stops.get(nj) else { break };
|
||
// Skip stops that wouldn't move the caret on screen or in the text.
|
||
if s.1 != pos.ch && (s.0 - cur_x).abs() > 0.01 {
|
||
return (Pos { para: l.para, ch: s.1 }, s.2);
|
||
}
|
||
j = nj;
|
||
}
|
||
}
|
||
// Off the line's edge: on to the neighbouring line in reading order.
|
||
let logical_forward = forward != l.rtl;
|
||
let target = if logical_forward { li.checked_add(1) } else { li.checked_sub(1) };
|
||
return match target.and_then(|t| self.lines.get(t)) {
|
||
Some(t) if logical_forward => (Pos { para: t.para, ch: t.start }, Affinity::Downstream),
|
||
Some(t) => (Pos { para: t.para, ch: t.end }, if t.para == l.para { Affinity::Upstream } else { Affinity::Downstream }),
|
||
None => (pos, affinity),
|
||
};
|
||
}
|
||
let Some(b) = self.boundaries.get(pos.para) else { return (pos, affinity) };
|
||
let len = b.len().saturating_sub(1);
|
||
let ch = pos.ch.min(len);
|
||
let next = if forward {
|
||
(ch + 1..=len).find(|&k| b.get(k).copied().unwrap_or(true))
|
||
} else {
|
||
(0..ch).rev().find(|&k| b.get(k).copied().unwrap_or(true))
|
||
};
|
||
match next {
|
||
Some(k) => (Pos { para: pos.para, ch: k }, Affinity::Downstream),
|
||
None if forward && pos.para + 1 < self.boundaries.len() => (Pos { para: pos.para + 1, ch: 0 }, Affinity::Downstream),
|
||
None if !forward && pos.para > 0 => {
|
||
let prev = pos.para - 1;
|
||
(Pos { para: prev, ch: self.boundaries.get(prev).map(|b| b.len().saturating_sub(1)).unwrap_or(0) }, Affinity::Downstream)
|
||
}
|
||
None => (Pos { para: pos.para, ch }, Affinity::Downstream),
|
||
}
|
||
}
|
||
/// Selection highlight rectangles between two positions: per line, the union of the visual
|
||
/// boxes of the selected characters (one logical range can be several boxes in bidi text).
|
||
pub fn selection_rects(&self, a: Pos, b: Pos) -> Vec<Rect> {
|
||
let (s, e) = if a <= b { (a, b) } else { (b, a) };
|
||
let mut out = vec![];
|
||
for l in &self.lines {
|
||
let lp = Pos { para: l.para, ch: l.start };
|
||
let le = Pos { para: l.para, ch: l.end };
|
||
if le < s || lp > e {
|
||
continue;
|
||
}
|
||
let from = if s > lp { s.ch } else { l.start };
|
||
let to = if e < le { e.ch } else { l.end };
|
||
let mut spans = l.spans(from, to);
|
||
// Selecting across a paragraph end shows a small extra box for the newline, at the
|
||
// paragraph's end side.
|
||
if e.para > l.para && to == l.end {
|
||
let end_x = l.caret_x.last().copied().unwrap_or(l.extent.1);
|
||
spans.push(if l.rtl { (end_x - 6.0, end_x) } else { (end_x, end_x + 6.0) });
|
||
spans = merge_spans(spans);
|
||
}
|
||
for (x0, x1) in spans {
|
||
if x1 > x0 {
|
||
out.push(Rect::new(x0, l.top, x1, l.bottom));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
||
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
||
let Some(li) = self.line_of(pos) else { return pos };
|
||
let target = if down { li + 1 } else { li.wrapping_sub(1) };
|
||
let Some(l) = self.lines.get(target) else { return pos };
|
||
let p = Point::new(x, (l.top + l.bottom) / 2.0);
|
||
let mut h = self.hit(p);
|
||
if h.para != l.para {
|
||
h.para = l.para;
|
||
h.ch = h.ch.clamp(l.start, l.end);
|
||
}
|
||
h
|
||
}
|
||
/// Start/end of the visual line containing `pos` (logical: Home/End).
|
||
pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) {
|
||
match self.line_of(pos).and_then(|i| self.lines.get(i)) {
|
||
Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }),
|
||
None => (pos, pos),
|
||
}
|
||
}
|
||
/// Bounding box of all glyphs/lines (shape-local).
|
||
pub fn bounds(&self) -> Rect {
|
||
let mut r: Option<Rect> = None;
|
||
for l in &self.lines {
|
||
let lr = Rect::new(l.extent.0, l.top, l.extent.1.max(l.extent.0), l.bottom);
|
||
r = Some(r.map(|a| a.union(lr)).unwrap_or(lr));
|
||
}
|
||
r.unwrap_or(self.inner)
|
||
}
|
||
}
|
||
|
||
/// Height the shape needs so its text fits (Resize shape to fit text): text height + insets.
|
||
pub fn fit_height(ctx: &Ctx, shape: &Shape, body: &TextBody, rect: Rect, fields: &dyn Fields) -> f64 {
|
||
let bp = resolve::body(ctx, shape);
|
||
let l = layout(ctx, shape, body, &Opts { rect, fields, prompt_color: None, no_shrink: true });
|
||
l.content_height + bp.inset_t.unwrap_or(3.6) + bp.inset_b.unwrap_or(3.6)
|
||
}
|
||
|
||
/// Width a non-wrapping text box needs.
|
||
pub fn fit_width(ctx: &Ctx, shape: &Shape, body: &TextBody, rect: Rect, fields: &dyn Fields) -> f64 {
|
||
let bp = resolve::body(ctx, shape);
|
||
let wide = Rect::new(rect.x0, rect.y0, rect.x0 + 100_000.0, rect.y1);
|
||
let mut s2 = shape.clone();
|
||
if let Some(t) = s2.text.as_mut() {
|
||
t.body.wrap = Some(false);
|
||
}
|
||
let l = layout(ctx, &s2, body, &Opts { rect: wide, fields, prompt_color: None, no_shrink: true });
|
||
let w = l.lines.iter().map(|li| li.start_offset + (li.extent.1 - li.extent.0)).fold(0.0, f64::max);
|
||
w + bp.inset_l.unwrap_or(7.2) + bp.inset_r.unwrap_or(7.2) + 1.0
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|