designcraft/crates/compose/src/lib.rs

1676 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! DesignCraft text composition.
//!
//! [`compose_story`] lays a story out through its chain of threaded frames:
//! style resolution → shaping ([`shape`]) → line breaking ([`breaker`]: the paragraph composer is
//! Knuth–Plass total fit, the single-line composer is greedy) → placement in columns and frames
//! (first-baseline offset, leading, space before/after, baseline grid, text wrap, column/frame
//! breaks, vertical justification) → overset detection.
//!
//! Output coordinates are each frame's *inner* space (apply the frame item's `xf` for spread space).
#![forbid(unsafe_code)]
pub mod breaker;
mod cache;
pub mod hyphen;
mod notes;
mod overlay;
pub mod shape;
pub mod table;
pub mod vars;
pub mod xref;
use std::collections::HashMap;
use std::ops::Range;
use std::sync::{Arc, Mutex};
use designcraft_doc::{
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
VerticalJustification, WrapMode, story,
};
use designcraft_fonts::FontDb;
use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, note_caret};
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell};
/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]).
#[derive(Clone, Debug, PartialEq)]
pub struct RunStyle {
pub fill: String,
pub fill_tint: f32,
pub stroke: String,
pub stroke_tint: f32,
pub stroke_weight: f64,
pub underline: bool,
pub strikethrough: bool,
/// The underline and strikethrough bars (Underline / Strikethrough Options resolved).
pub underline_rule: Rule,
pub strike_rule: Rule,
pub skew: f64,
pub size: f64,
/// The run's font isn't installed (shown in a substitute; highlighted on screen).
pub missing_font: bool,
}
/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative =
/// above), thickness and colour.
#[derive(Clone, Debug, PartialEq)]
pub struct Rule {
pub offset: f64,
pub weight: f64,
pub color: String,
pub tint: f32,
}
impl RunStyle {
/// The bars this run draws between `x0` and `x1` on a line at `baseline`.
pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator<Item = (&Rule, Rect)> {
[(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)]
.into_iter()
.filter(|(on, _)| *on)
.map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight)))
}
}
/// A positioned glyph. `x` is absolute in frame inner space; `y` is relative to the line baseline.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
pub face: designcraft_fonts::FaceRef,
pub gid: u32,
pub x: f64,
pub y: f64,
pub adv: f64,
pub sx: f64,
pub sy: f64,
pub style: u32,
pub byte: usize,
pub len: usize,
/// Control characters (tabs, breaks, markers' carriers) are not drawn.
pub visible: bool,
}
#[derive(Clone, Debug)]
pub struct Line {
pub column: u32,
/// Baseline y in frame inner space.
pub baseline: f64,
/// Line slot (column minus wrap) horizontal extent.
pub x0: f64,
pub x1: f64,
pub ascent: f64,
pub descent: f64,
pub leading: f64,
/// Story byte range (excluding the paragraph separator).
pub range: Range<usize>,
pub para: usize,
pub glyphs: Vec<PlacedGlyph>,
pub hyphenated: bool,
pub first_in_para: bool,
pub last_in_para: bool,
/// x after the last glyph (caret at line end).
pub end_x: f64,
/// Word-space ratio actually used vs desired (H&J violation highlighting), 1.0 = desired.
pub spacing: f64,
}
/// A paragraph rule or shading rectangle in frame inner space.
#[derive(Clone, Debug, PartialEq)]
pub struct Deco {
pub rect: Rect,
pub color: String,
pub tint: f32,
}
#[derive(Clone, Debug, Default)]
pub struct FrameText {
pub frame: ItemId,
pub lines: Vec<Line>,
pub decos: Vec<Deco>,
/// Byte range of the story shown in this frame.
pub range: Range<usize>,
/// Column rectangles (inner space) for drawing column guides / hit testing.
pub columns: Vec<Rect>,
/// Height the text needs (for auto-sizing), from the text area top.
pub content_height: f64,
/// Table fragments placed in this frame (each also has a glyph-less line in `lines`).
pub tables: Vec<TableFrag>,
/// Footnotes placed at the bottom of this frame's columns.
pub notes: Vec<PlacedNote>,
/// Anchored objects placed in this frame's lines.
pub objects: Vec<PlacedObject>,
}
/// An anchored object placed in the text.
#[derive(Clone, Debug)]
pub struct PlacedObject {
/// Index in the story's [`Story::objects`].
pub index: usize,
/// Top-left of the object in frame inner space.
pub origin: Point,
pub size: (f64, f64),
/// The line it sits on (or above).
pub line: usize,
x: f64,
text_ascent: f64,
}
/// A footnote composed at the bottom of a column.
#[derive(Clone, Debug)]
pub struct PlacedNote {
/// Footnote id (in the story's [`Story::notes`]) and its index there.
pub id: u64,
pub index: usize,
pub column: u32,
/// The number shown.
pub label: String,
/// Where the footnote's composed text (its frame 0) sits in the frame.
pub origin: Point,
/// Area of the footnote in frame inner space.
pub rect: Rect,
pub text: Arc<ComposedStory>,
/// The footnote's story text (exporters map glyphs back to text).
pub source: Arc<str>,
}
#[derive(Clone, Debug, Default)]
pub struct ComposedStory {
pub story: StoryId,
pub rev: u64,
pub frames: Vec<FrameText>,
/// First byte that didn't fit (overset text), if any.
pub overset_at: Option<usize>,
pub styles: Vec<RunStyle>,
pub text_len: usize,
}
impl ComposedStory {
pub fn is_overset(&self) -> bool {
self.overset_at.is_some()
}
pub fn frame(&self, id: ItemId) -> Option<&FrameText> {
self.frames.iter().find(|f| f.frame == id)
}
/// Number of lines in all frames.
pub fn line_count(&self) -> usize {
self.frames.iter().map(|f| f.lines.len()).sum()
}
}
/// A text-wrap exclusion in a frame's inner space.
#[derive(Clone, Debug, PartialEq)]
pub struct Exclusion {
pub rect: Rect,
pub mode: WrapMode,
}
/// One frame of the thread, ready for composition.
#[derive(Clone, Debug)]
pub struct FrameSpec {
pub id: ItemId,
/// Text area (inner space, after inset).
pub area: Rect,
pub opts: TextFrameOptions,
pub exclusions: Vec<Exclusion>,
pub page_name: Option<String>,
/// Absolute document page the frame is on (None on parent pages).
pub page: Option<usize>,
/// Baseline grid in inner space: (first grid line y, increment).
pub grid: Option<(f64, f64)>,
/// The frame is on a left page (for towards/away-from-spine alignment).
pub left_page: bool,
}
impl FrameSpec {
pub fn columns(&self) -> Vec<Rect> {
let a = self.area;
let n = self.opts.columns.max(1);
match self.opts.columns_kind {
designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n)
.map(|i| {
let x = a.x0 + i as f64 * (self.opts.column_width + self.opts.gutter);
Rect::new(x, a.y0, x + self.opts.column_width, a.y1)
})
.collect(),
_ => designcraft_doc::page::column_rects(a, n, self.opts.gutter),
}
}
}
/// Composition inputs that are not in the story itself.
#[derive(Clone, Debug, Default)]
pub struct ComposeOptions {
/// Page name for page-number markers when the story is composed for a specific page (parent items).
pub page_name: Option<String>,
/// Absolute page for text variables when composed for a specific page (parent items).
pub page: Option<usize>,
/// Running-header index (needed when the story contains running-header variables).
pub running: Option<std::sync::Arc<vars::RunningIndex>>,
/// Generated text before the first paragraph (a footnote's number and separator).
pub label: Option<String>,
/// Anchor pages for cross-references (needed when the story contains cross-references).
pub xrefs: Option<Arc<xref::XrefIndex>>,
}
/// Build the frame specs of a story from the document (geometry, wrap, page names, grid).
pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
let Some(st) = doc.story(sid) else { return vec![] };
let mut out = Vec::with_capacity(st.frames.len());
for &fid in &st.frames {
let Some(loc) = doc.find(fid) else { continue };
let Some(item) = doc.item_at(&loc) else { continue };
let Some(tf) = item.text_frame() else { continue };
let xf = doc.parent_xf(&loc) * item.xf;
let inv = xf.inverse();
let mut exclusions = Vec::new();
let spread = doc.spread(loc.spread);
if !tf.options.ignore_wrap
&& let Some(sp) = spread
{
for other in &sp.items {
if other.id == item.id || other.wrap.mode == WrapMode::None || other.hidden {
continue;
}
if doc.layer(other.layer).is_some_and(|l| !l.visible) {
continue;
}
let o = other.wrap.offsets;
let b = other.bounds();
let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]);
let inner = inv.transform_rect_bbox(r);
exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode });
}
}
let (page_name, page, left_page) = match (loc.spread, spread) {
(designcraft_doc::SpreadRef::Doc(si), Some(sp)) => {
let c = item.bounds().center();
let pi = sp.page_at_x(c.x).unwrap_or(0);
let abs = doc.first_page_of_spread(si) + pi;
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
}
(designcraft_doc::SpreadRef::Parent(pi), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
let _ = pi;
(Some(prefix), None, false)
}
_ => (None, None, false),
};
let g = &doc.settings.baseline_grid;
let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start));
// Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only).
let ty = xf.translation().y;
let grid = (inc > 0.0).then_some((start - ty, inc));
// Type on a path: one line as long as the path (from the start offset).
let (area, opts) = match &tf.options.path {
Some(pt) => {
let len = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), false).length();
let size = st
.paras
.first()
.map(|p| doc.styles.resolve_para(p).1)
.map(|base| st.runs().map(|(_, f)| doc.styles.resolve_char(&base, f).size).fold(base.size, f64::max))
.unwrap_or(12.0);
let opts = TextFrameOptions {
columns: 1,
inset: [0.0; 4],
first_baseline: designcraft_doc::FirstBaseline::Ascent,
path: None,
..tf.options.clone()
};
(Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts)
}
None => (item.text_area(), tf.options.clone()),
};
out.push(FrameSpec {
id: fid,
area,
opts,
exclusions: if tf.options.path.is_some() { vec![] } else { exclusions },
page_name,
page,
grid,
left_page,
});
}
out
}
/// Compose a story of `doc` through its frames.
pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory {
let specs = frame_specs(doc, sid);
match doc.story(sid) {
Some(st) => compose(doc, st, &specs, opts),
None => ComposedStory { story: sid, ..Default::default() },
}
}
const DEFAULT_TAB: f64 = 36.0;
/// Compose `story` into `frames`.
pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions) -> ComposedStory {
let db = FontDb::global();
let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() };
let mut styles_tab: Vec<RunStyle> = Vec::new();
let mut missing_fonts: HashMap<String, bool> = HashMap::new();
out.frames = frames.iter().map(|f| FrameText { frame: f.id, columns: f.columns(), ..Default::default() }).collect();
let cols: Vec<Vec<Rect>> = frames.iter().map(FrameSpec::columns).collect();
let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, pending: 0.0 };
let para_ranges = story.para_ranges();
let np = para_ranges.len();
let mut list_counter: u32 = 0;
// Keep options: paragraphs are re-laid from a snapshot when a keep is violated, either forced
// into the next column or with a cap on the lines set before moving on (widow control).
let mut snaps: Vec<Snapshot> = Vec::with_capacity(np);
let mut info: Vec<ParaInfo> = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec<Option<usize>> = vec![None; np];
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
let mut notes = Notes::new(doc, story);
let mut note_snaps: Vec<(u32, Option<usize>, usize)> = Vec::with_capacity(np);
let mut var_cache: std::collections::HashMap<Option<usize>, std::sync::Arc<Vec<String>>> = Default::default();
'paras: while pi < np {
let prange = para_ranges[pi].clone();
let snap = Snapshot::take(&out, &cur, list_counter);
snaps.truncate(pi);
snaps.push(snap);
note_snaps.truncate(pi);
note_snaps.push((notes.num, notes.key, notes.placed.len()));
info.truncate(pi);
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
if let Some(t) = story.para_table(pi) {
if cur.last_baseline.is_some() {
match pp.start_paragraph {
StartParagraph::NextColumn => cur.next_column(&cols),
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => {
cur.next_frame()
}
StartParagraph::Anywhere => {}
}
}
let at_top = cur.last_baseline.is_none();
let start_at = (cur.fi, cur.col);
let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0);
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
out.overset_at = Some(prange.start);
break 'paras;
}
info.push(ParaInfo {
start: start_at,
at_top,
end: (cur.fi, cur.col),
lines: 1,
lines_in_end_col: 1,
keep_with_next: 0,
keep_together: false,
keep_all: false,
keep_first: 1,
keep_last: 1,
});
list_counter = 0;
cur.pending += pp.space_after;
pi += 1;
continue;
}
// Bullets & numbering: generated prefix (shaped as its own glyphs, mapped to the paragraph start).
let cur_frame = frames.get(cur.fi.min(frames.len().saturating_sub(1)));
let var_values = if var_story {
let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page));
var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone()
} else {
Default::default()
};
let mut sub = SubstCtx {
page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())),
section_marker: None,
vars: var_values,
..Default::default()
};
if !story.objects.is_empty() {
let before = story.text[..prange.start].matches(designcraft_doc::OBJECT_MARK).count();
for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::OBJECT_MARK).enumerate() {
let Some(o) = story.objects.get(before + k) else { continue };
let (w, h) = o.size();
let (y_offset, space) = match &o.position {
designcraft_doc::AnchorPosition::Inline { y_offset } => (Some(*y_offset), 0.0),
designcraft_doc::AnchorPosition::AboveLine { space_before, space_after, .. } => (None, space_before + space_after),
};
sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space });
}
}
if !story.xrefs.is_empty() {
sub.xrefs = xref::texts_in(doc, story, prange.clone(), opts.xrefs.as_deref());
}
if notes.active() {
notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub);
}
let sub_objects = sub.objects.clone();
let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts };
let mut sp = shape::shape_para(
db,
&doc.styles,
story,
pi,
prange.clone(),
&base_chars,
pp.auto_leading,
&sub,
&mut table,
&pp.nested_styles,
&pp.grep_styles,
);
match pp.list_type {
designcraft_doc::ListType::Numbers => {
list_counter += 1;
let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
}
designcraft_doc::ListType::Bullets => {
let label = format!("{}{}", pp.bullet_char, pp.list_separator);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
}
designcraft_doc::ListType::None => list_counter = 0,
}
if pi == 0
&& let Some(label) = &opts.label
{
prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, &pp, &mut table);
}
let mut glyphs = sp.glyphs;
apply_desired_spacing(&mut glyphs, &pp);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp);
let base_size = base_chars.size;
let base_leading = match base_chars.leading {
designcraft_doc::Leading::Auto => base_size * pp.auto_leading,
designcraft_doc::Leading::Points(v) => v,
};
let spacing = spacing_for(&pp, base_size);
// Paragraph start options.
if force_col[pi] {
cur.next_column(&cols);
}
if cur.last_baseline.is_some() {
match pp.start_paragraph {
StartParagraph::NextColumn => cur.next_column(&cols),
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(),
StartParagraph::Anywhere => {}
}
}
if cur.last_baseline.is_some() {
cur.pending += pp.space_before;
}
let at_top = cur.last_baseline.is_none();
let start_at = (cur.fi, cur.col);
// Rule above / shading track the paragraph's first line.
let mut g0 = 0usize;
let mut line_no = 0usize;
// Paragraph line number of the first line in the current column.
let mut col_first_line = 0usize;
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
loop {
if cur.fi >= frames.len() {
out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start));
break 'paras;
}
let f = &frames[cur.fi];
let col = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
let col = match pp.span_columns {
SpanColumns::Span(n) if cur.col == 0 => {
let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) };
Rect::new(col.x0, col.y0, cols[cur.fi][n - 1].x1, col.y1)
}
_ => col,
};
// Estimate slots for the breaker with the paragraph's base leading.
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let width = |j: usize| -> f64 {
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
(x1 - x0 - ind).max(1.0)
};
let rest = &glyphs[g0..];
let rest_h = &hyph_after[g0..];
let breaks: Vec<Break> = if pp.composer == Composer::SingleLine || has_tabs || rest.len() > 4000 {
breaker::greedy(rest, rest_h, &spacing, &width)
} else if pp.balance_ragged && !spacing.justify {
breaker::balanced(rest, rest_h, &spacing, &width)
} else {
breaker::knuth_plass(rest, rest_h, &spacing, &width)
};
let mut moved = false;
for (k, b) in breaks.iter().enumerate() {
let (s, e) = (g0 + b.start, g0 + b.end);
let line_glyphs = &glyphs[s..e.max(s)];
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
// Baseline grid.
if let Some((g_start, inc)) = f.grid
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
&& inc > 0.0
{
let n = ((baseline - g_start) / inc - 1e-6).ceil();
baseline = g_start + n * inc;
}
// Wrap: push the line down until a slot exists.
let (mut x0, mut x1) = (col.x0, col.x1);
if !f.exclusions.is_empty() {
let mut tries = 0;
loop {
match free_slot(f, col, baseline - asc, baseline + desc, base_size) {
Some((a, b)) => {
x0 = a;
x1 = b;
break;
}
None => {
baseline += 1.0;
tries += 1;
if baseline > col.y1 || tries > 4000 {
break;
}
}
}
}
}
let capped = line_cap[pi] == Some(line_no) && line_no > col_first_line;
// Footnotes referenced on this line need room at the bottom of the column too
// (a line at the top of a column is set anyway).
let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() };
let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width();
let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts);
let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve };
let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped;
if !fits {
if !capped {
let ctx = KeepCtx {
pi,
line_no,
in_col: line_no - col_first_line,
started_here: col_first_line == 0,
at_top,
remaining: breaks.len() - k,
here: (cur.fi, cur.col),
};
let action = if restores < 4 * np + 64 { keep_violation(&ctx, &pp, &info, &force_col, &line_cap) } else { None };
if let Some(action) = action {
restores += 1;
let j = match action {
KeepAction::Force(j) => {
force_col[j] = true;
j
}
KeepAction::Cap(j, n) => {
line_cap[j] = Some(n);
j
}
};
for k in j + 1..np {
force_col[k] = false;
line_cap[k] = None;
}
snaps[j].restore(&mut out, &mut cur, &mut list_counter);
(notes.num, notes.key) = (note_snaps[j].0, note_snaps[j].1);
notes.placed.truncate(note_snaps[j].2);
pi = j;
continue 'paras;
}
}
// Next column / frame; re-break the rest of the paragraph there.
g0 = s;
cur.next_column(&cols);
col_first_line = line_no;
moved = true;
break;
}
let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 };
let lx0 = x0 + ind_l;
let lx1 = x1 - pp.right_indent;
let last = k + 1 == breaks.len();
let (placed, end_x, ratio) =
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page);
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
let range_start = if line_no == 0 { prange.start } else { range_start };
let ft = &mut out.frames[cur.fi];
if line_no == 0 {
first_line_rect = Some((cur.fi, baseline, asc, x0));
}
ft.lines.push(Line {
column: cur.col as u32,
baseline,
x0,
x1,
ascent: asc,
descent: desc,
leading: lead,
range: range_start..range_end,
para: pi,
glyphs: placed,
hyphenated: b.hyphen,
first_in_para: line_no == 0,
last_in_para: last,
end_x,
spacing: ratio,
});
for k in line_notes {
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
}
if !sub_objects.is_empty() {
let ft = &mut out.frames[cur.fi];
let li = ft.lines.len() - 1;
let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max);
let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 };
for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) {
if let Some(o) = sub_objects.get(&g.byte) {
ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent });
}
}
}
cur.last_baseline = Some(baseline);
cur.last_descent = desc;
cur.pending = 0.0;
line_no += 1;
// Column / frame / page break characters.
if b.forced && e < glyphs.len() + 1 {
let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch);
let jumped = match brk {
Some(story::COLUMN_BREAK) => {
cur.next_column(&cols);
true
}
Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => {
cur.next_frame();
true
}
_ => false,
};
if jumped {
// The rest of the paragraph continues in the new column/frame: re-break it there.
col_first_line = line_no;
if k + 1 < breaks.len() {
g0 += b.next;
moved = true;
break;
}
}
}
}
if !moved {
break;
}
}
info.push(ParaInfo {
start: start_at,
at_top,
end: (cur.fi, cur.col),
lines: line_no,
lines_in_end_col: line_no - col_first_line,
keep_with_next: pp.keep_with_next,
keep_together: pp.keep_lines_together,
keep_all: pp.keep_all_lines,
keep_first: pp.keep_first as usize,
keep_last: pp.keep_last as usize,
});
// Rules and shading for the paragraph.
if let Some((fi, bl, asc, _)) = first_line_rect {
let ft = &mut out.frames[fi];
if pp.rule_above.on {
let r = &pp.rule_above;
let y = bl - asc - r.offset;
let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0));
ft.decos.push(Deco {
rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y),
color: r.color.clone(),
tint: r.tint,
});
}
if pp.shading_on {
let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect();
if let (Some(a), Some(z)) = (lines.first(), lines.last()) {
ft.decos.insert(
0,
Deco {
rect: Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent),
color: pp.shading_color.clone(),
tint: pp.shading_tint,
},
);
}
}
}
if pp.rule_below.on
&& let Some(ft) = out.frames.get_mut(cur.fi.min(frames.len().saturating_sub(1)))
&& let Some(l) = ft.lines.iter().rev().find(|l| l.para == pi)
{
let r = &pp.rule_below;
let y = l.baseline + r.offset;
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint });
}
cur.pending += pp.space_after;
pi += 1;
}
notes.finish(doc, story, frames, &cols, &mut out);
// Ranges, content heights and vertical justification.
for (fi, ft) in out.frames.iter_mut().enumerate() {
let f = &frames[fi];
if let (Some(a), Some(z)) = (ft.lines.first(), ft.lines.last()) {
ft.range = a.range.start..z.range.end;
ft.content_height = z.baseline + z.descent - f.area.y0;
} else {
let p = ft_prev_end(&out.overset_at, story.text.len());
ft.range = p..p;
}
let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
vertical_justify(ft, f);
for (t, b) in ft.tables.iter_mut().zip(before) {
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
t.shift(now - b);
}
// Anchored objects follow their (finally placed) lines.
if !ft.objects.is_empty() {
for o in &mut ft.objects {
let Some(l) = ft.lines.get(o.line) else { continue };
let Some(obj) = story.objects.get(o.index) else { continue };
let (w, h) = o.size;
o.origin = match &obj.position {
designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h),
designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => {
let x = match align {
designcraft_doc::anchored::AnchorAlign::Left => l.x0,
designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0,
designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w,
};
Point::new(x, l.baseline - o.text_ascent - space_after - h)
}
};
}
}
}
// Frame ranges for empty frames after the text: start at the end of the text shown.
let mut last_end = 0;
for ft in &mut out.frames {
if ft.lines.is_empty() {
let p = out.overset_at.unwrap_or(last_end).max(last_end);
ft.range = p..p;
} else {
last_end = ft.range.end;
}
}
out.styles = styles_tab;
out
}
/// Breaker parameters from the paragraph's settings.
fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
Spacing {
justify: pp.align.is_justified(),
word_min: pp.word_space_min,
word_desired: pp.word_space_desired,
word_max: pp.word_space_max,
letter_min: pp.letter_space_min.min(pp.letter_space_desired),
letter_desired: pp.letter_space_desired,
letter_max: pp.letter_space_max.max(pp.letter_space_desired),
glyph_min: pp.glyph_scale_min.min(pp.glyph_scale_desired).max(0.01),
glyph_desired: pp.glyph_scale_desired.max(0.01),
glyph_max: pp.glyph_scale_max.max(pp.glyph_scale_desired),
hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight,
hyphen_limit: pp.hyph_limit,
ragged_stretch: base_size * 2.0,
hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
}
}
/// Desired word spacing, letter spacing and glyph scaling apply to every line (any alignment).
fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
let ws = pp.word_space_desired;
let ls = pp.letter_space_desired;
let gs = pp.glyph_scale_desired.max(0.01);
if (ws - 1.0).abs() < 1e-9 && ls.abs() < 1e-9 && (gs - 1.0).abs() < 1e-9 {
return;
}
for g in glyphs {
if g.ch == ' ' {
g.adv += g.space * (ws - 1.0);
} else if !g.is_space() && g.adv > 0.0 {
g.adv = g.adv * gs + ls * g.space;
g.sx *= gs;
g.dx *= gs;
}
}
}
/// Lengths of the composed output and the cursor before a paragraph (keep resolution re-lays
/// paragraphs from here). Only the cursor's frame and later ones can change afterwards.
struct Snapshot {
cur: Cursor,
list_counter: u32,
lines: usize,
decos: usize,
tables: usize,
}
impl Snapshot {
fn take(out: &ComposedStory, cur: &Cursor, list_counter: u32) -> Snapshot {
let (lines, decos, tables) = out.frames.get(cur.fi).map_or((0, 0, 0), |f| (f.lines.len(), f.decos.len(), f.tables.len()));
Snapshot { cur: cur.clone(), list_counter, lines, decos, tables }
}
fn restore(&self, out: &mut ComposedStory, cur: &mut Cursor, list_counter: &mut u32) {
let fi = self.cur.fi;
for (i, f) in out.frames.iter_mut().enumerate().skip(fi) {
let (l, d, t) = if i == fi { (self.lines, self.decos, self.tables) } else { (0, 0, 0) };
f.lines.truncate(l);
f.decos.truncate(d);
f.tables.truncate(t);
f.objects.retain(|o| o.line < l);
}
*cur = self.cur.clone();
*list_counter = self.list_counter;
out.overset_at = None;
}
}
/// Where a placed paragraph sits (for keep resolution).
#[derive(Clone, Debug)]
struct ParaInfo {
start: (usize, usize),
/// Started at the top of a column (moving it can't help).
at_top: bool,
end: (usize, usize),
lines: usize,
lines_in_end_col: usize,
keep_with_next: u32,
keep_together: bool,
keep_all: bool,
keep_first: usize,
keep_last: usize,
}
/// The paragraph being placed when a line doesn't fit its column.
struct KeepCtx {
pi: usize,
/// Paragraph line number of the line that doesn't fit.
line_no: usize,
/// Lines of this paragraph already in the column.
in_col: usize,
/// The paragraph started in this column.
started_here: bool,
at_top: bool,
/// Lines left to set, including the one that doesn't fit.
remaining: usize,
here: (usize, usize),
}
enum KeepAction {
/// Re-lay from paragraph `j`, starting it in the next column.
Force(usize),
/// Re-lay from paragraph `j`, moving to the next column after `n` of its lines.
Cap(usize, usize),
}
/// Keep Options (plan: typography §8): keep with next, keep all lines together, keep first/last
/// lines. Returns how to re-lay, or None to just continue in the next column (also when a keep
/// can't be satisfied, e.g. a paragraph already at the top of a column).
fn keep_violation(ctx: &KeepCtx, pp: &ParaProps, info: &[ParaInfo], force_col: &[bool], line_cap: &[Option<usize>]) -> Option<KeepAction> {
// The first paragraph of the chain of keep-with-next paragraphs ending in `pi` that sit in this column.
let chain_start = |pi: usize| {
let mut j = pi;
while j > 0 {
let p = &info[j - 1];
let starts_here = if j == ctx.pi { ctx.started_here } else { info[j].start == ctx.here };
if p.keep_with_next > 0 && p.end == ctx.here && starts_here {
j -= 1;
} else {
break;
}
}
j
};
let force = |j: usize| -> Option<KeepAction> {
let top = if j == ctx.pi { ctx.at_top } else { info[j].at_top };
let starts_here = if j == ctx.pi { ctx.started_here } else { info[j].start == ctx.here };
(!(force_col[j] || top && starts_here)).then_some(KeepAction::Force(j))
};
let total_lines = ctx.line_no + ctx.remaining;
// Keep with next: the previous paragraph's last line must share a column with our first lines.
if ctx.started_here && ctx.pi > 0 {
let prev = &info[ctx.pi - 1];
let need = (prev.keep_with_next as usize).min(total_lines);
if prev.keep_with_next > 0 && prev.end == ctx.here && ctx.line_no < need {
let j = chain_start(ctx.pi);
if j == ctx.pi - 1 && prev.lines_in_end_col > 1 && !(prev.keep_together && prev.keep_all) && line_cap[j].is_none() {
// Move only the previous paragraph's last lines (respecting its widow control).
let keep_last = if prev.keep_together { prev.keep_last.max(1) } else { 1 };
let stay = prev.lines_in_end_col.saturating_sub(keep_last);
let min_stay = if prev.start == ctx.here { if prev.keep_together { prev.keep_first.max(1) } else { 1 } } else { 1 };
if stay >= min_stay {
return Some(KeepAction::Cap(j, prev.lines - keep_last));
}
}
if let Some(a) = force(j) {
return Some(a);
}
}
}
if !pp.keep_lines_together || ctx.in_col == 0 {
return None;
}
if pp.keep_all_lines {
return if ctx.started_here { force(chain_start(ctx.pi)) } else { None };
}
// Orphan control: at least `keep_first` lines at the bottom of the column.
if ctx.started_here && ctx.in_col < (pp.keep_first as usize).max(1) {
return force(chain_start(ctx.pi));
}
// Widow control: at least `keep_last` lines at the top of the next column.
let keep_last = (pp.keep_last as usize).max(1);
if ctx.remaining < keep_last {
let need = keep_last - ctx.remaining;
let min_stay = if ctx.started_here { (pp.keep_first as usize).max(1) } else { 1 };
if ctx.in_col >= need + min_stay && line_cap[ctx.pi].is_none() {
return Some(KeepAction::Cap(ctx.pi, ctx.line_no - need));
}
if ctx.started_here {
return force(chain_start(ctx.pi));
}
}
None
}
fn ft_prev_end(overset: &Option<usize>, len: usize) -> usize {
overset.unwrap_or(len)
}
#[derive(Clone)]
struct Cursor {
fi: usize,
col: usize,
last_baseline: Option<f64>,
last_descent: f64,
/// Space before/after waiting to be added to the next line.
pending: f64,
}
impl Cursor {
fn next_column(&mut self, cols: &[Vec<Rect>]) {
self.col += 1;
if self.fi < cols.len() && self.col >= cols[self.fi].len() {
self.col = 0;
self.fi += 1;
}
self.last_baseline = None;
self.pending = 0.0;
}
fn next_frame(&mut self) {
self.fi += 1;
self.col = 0;
self.last_baseline = None;
self.pending = 0.0;
}
/// Baseline for the next line with leading `lead` and ascent `asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
match self.last_baseline {
Some(b) => b + lead + self.pending,
None => {
let off = match f.opts.first_baseline {
FirstBaseline::Ascent => asc,
FirstBaseline::CapHeight => asc * 0.72,
FirstBaseline::XHeight => asc * 0.5,
FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0,
};
col.y0 + off.max(f.opts.first_baseline_min)
}
}
}
}
fn line_metrics(
line: &[Glyph],
all: &[Glyph],
s: usize,
base_leading: f64,
base_size: f64,
db: &FontDb,
base: &designcraft_doc::CharProps,
) -> (f64, f64, f64) {
let _ = db;
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
if src.is_empty() {
let face = FontDb::global().face(&base.font_family, &base.font_style);
let k = base_size / face.upem;
return (face.ascent * k, face.descent * k, base_leading);
}
let mut asc: f64 = 0.0;
let mut desc: f64 = 0.0;
let mut lead: f64 = 0.0;
for g in src {
asc = asc.max(g.ascent + g.shift.max(0.0));
desc = desc.max(g.descent - g.shift.min(0.0));
lead = lead.max(g.leading);
}
(asc, desc, lead)
}
/// Widest free horizontal interval of `col` in the band, or None if blocked.
fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> {
let mut free = vec![(col.x0, col.x1)];
for ex in &f.exclusions {
let r = ex.rect;
let overlaps_band = r.y0 < y1 && r.y1 > y0;
match ex.mode {
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => {
let mut next = Vec::new();
for (a, b) in free {
if r.x1 <= a || r.x0 >= b {
next.push((a, b));
continue;
}
if r.x0 > a {
next.push((a, r.x0));
}
if r.x1 < b {
next.push((r.x1, b));
}
}
free = next;
}
_ => {}
}
}
free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))
}
#[allow(clippy::too_many_arguments)]
fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> {
let _ = (pp, line_no);
if f.exclusions.is_empty() {
return vec![];
}
// Rough upper bound on lines: total advance / column width × 2.
let total: f64 = glyphs.iter().map(|g| g.adv).sum();
let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000);
let mut v = Vec::with_capacity(n);
let mut b = first;
for _ in 0..n {
let mut tries = 0;
let slot = loop {
if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) {
break s;
}
b += 1.0;
tries += 1;
if b > col.y1 || tries > 4000 {
break (col.x0, col.x1);
}
};
v.push(slot);
b += lead;
}
v
}
/// Position glyphs `s..e` within `[x0, x1]`; returns (glyphs, end x, word-space ratio).
///
/// Justified lines distribute the difference to the measure in priority order: word spaces up to
/// their limit, then letter spacing, then glyph scaling, and anything left over to the word spaces
/// (an H&J violation). With optical margin alignment, edge punctuation hangs outside `[x0, x1]`.
#[allow(clippy::too_many_arguments)]
fn layout_line(
glyphs: &[Glyph],
s: usize,
e: usize,
hyphen: bool,
x0: f64,
x1: f64,
tab_origin: f64,
pp: &ParaProps,
sp: &Spacing,
last: bool,
forced_mid: bool,
left_page: bool,
) -> (Vec<PlacedGlyph>, f64, f64) {
let mut line: Vec<Glyph> = glyphs[s..e.max(s)].to_vec();
if hyphen && let Some(g) = line.last() {
let h = shape::hyphen_after(g);
line.push(h);
}
// Tabs: compute widths left to right.
let measure = x1 - x0;
let mut x = 0.0;
let mut i = 0;
let mut leaders: Vec<(usize, String)> = Vec::new();
while i < line.len() {
if line[i].ch == '\t' {
let abs = x0 + x - tab_origin;
let stop = pp.tabs.iter().find(|t| t.position > abs + 0.01).cloned();
if let Some(t) = &stop
&& !t.leader.is_empty()
{
leaders.push((i, t.leader.clone()));
}
let (pos, align) = match &stop {
Some(t) => (t.position, t.align),
None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left),
};
// Width of the text after this tab up to the next tab / line end.
let seg_end = line[i + 1..].iter().position(|g| g.ch == '\t').map_or(line.len(), |p| i + 1 + p);
let seg_w: f64 = line[i + 1..seg_end].iter().map(|g| g.adv).sum();
let target = pos + tab_origin - x0;
let w = match align {
TabAlign::Left => target - x,
TabAlign::Right => target - x - seg_w,
TabAlign::Center => target - x - seg_w / 2.0,
TabAlign::Char => {
let ch = stop.as_ref().and_then(|t| t.align_on.chars().next()).unwrap_or('.');
let before: f64 = line[i + 1..seg_end].iter().take_while(|g| g.ch != ch).map(|g| g.adv).sum();
target - x - before
}
};
line[i].adv = w.max(0.0);
} else if line[i].ch == story::RIGHT_INDENT_TAB {
let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum();
line[i].adv = (measure - x - rest).max(0.0);
}
x += line[i].adv;
i += 1;
}
let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB);
// Optical margin alignment: the measure grows by the hang of the edge glyphs.
let (mut x0, mut measure) = (x0, measure);
if sp.optical && !has_tab {
let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space());
let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space());
let hl = first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv);
let hr = lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv);
x0 -= hl;
measure += hl + hr;
}
let natural: f64 = line.iter().map(|g| g.adv).sum();
let extra = measure - natural;
let spaces: Vec<usize> = line.iter().enumerate().filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect();
let align = match pp.align {
Align::TowardsSpine => {
if left_page {
Align::Right
} else {
Align::Left
}
}
Align::AwayFromSpine => {
if left_page {
Align::Left
} else {
Align::Right
}
}
a => a,
};
let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab;
// A justified paragraph's last line may have been composed with shrunk spaces: shrink it too.
let squeeze_last = align.is_justified() && !justify_this && !has_tab && extra < 0.0 && !spaces.is_empty();
// Extra advance per glyph (word spaces and letter gaps) and horizontal scale per glyph.
let mut add = vec![0.0; line.len()];
let mut scale = vec![1.0; line.len()];
let mut offset = 0.0;
if (justify_this || squeeze_last) && !spaces.is_empty() {
distribute(&line, &spaces, extra, sp, &mut add, &mut scale);
} else if justify_this && spaces.is_empty() && line.len() > 1 && !last {
// Single word: Single Word Justification.
match pp.single_word_justify {
Align::FullyJustified => {
let per = extra / (line.len() - 1) as f64;
for a in &mut add[..line.len() - 1] {
*a = per;
}
}
Align::Center => offset = extra / 2.0,
Align::Right => offset = extra,
_ => {}
}
} else {
let last_align = match align {
Align::LeftJustified => Align::Left,
Align::CenterJustified => Align::Center,
Align::RightJustified => Align::Right,
Align::FullyJustified => Align::Left,
a => a,
};
offset = match last_align {
Align::Center => extra / 2.0,
Align::Right => extra,
_ => 0.0,
};
// Overfull ragged lines are not shifted left of the slot.
if offset < 0.0 {
offset = 0.0;
}
}
// Word-space ratio actually used (vs the space width), for H&J highlighting.
let mut ratio_sum = 0.0;
let mut ratio_n = 0usize;
for &i in &spaces {
let g = &line[i];
if g.ch == ' ' && g.space > 0.0 {
ratio_sum += (g.adv + add[i]) / g.space;
ratio_n += 1;
}
}
let ratio = if ratio_n > 0 { ratio_sum / ratio_n as f64 } else { 1.0 };
let mut out = Vec::with_capacity(line.len());
let mut x = x0 + offset;
for (i, g) in line.iter().enumerate() {
let mut p = place(g, x);
if scale[i] != 1.0 {
p.sx *= scale[i];
p.x = x + g.dx * scale[i];
p.adv *= scale[i];
}
p.adv += add[i];
let leader = leaders.iter().find(|(k, _)| *k == i).map(|(_, l)| l.as_str());
let (tab_x, tab_w) = (x, p.adv);
x += p.adv;
out.push(p);
if let Some(l) = leader {
tab_leader(g, l, tab_x, tab_w, tab_origin, &mut out);
}
}
(out, x, ratio)
}
/// Fill a tab's gap with repeats of its leader, on a grid shared by all lines (so dots align).
fn tab_leader(tab: &Glyph, leader: &str, x: f64, w: f64, origin: f64, out: &mut Vec<PlacedGlyph>) {
let glyphs: Vec<(u32, f64)> = leader
.chars()
.map(|c| {
let gid = designcraft_fonts::first_glyph(&tab.face, &[c]);
(gid, tab.face.advance(gid) * tab.sx)
})
.collect();
let pw: f64 = glyphs.iter().map(|g| g.1).sum();
if pw <= 0.01 || w < pw {
return;
}
let mut at = origin + ((x - origin) / pw).ceil() * pw;
let end = x + w;
let mut n = 0;
while at + pw <= end + 0.01 && n < 2000 {
for &(gid, adv) in &glyphs {
out.push(PlacedGlyph {
face: tab.face,
gid,
x: at,
y: -tab.shift + tab.dy,
adv: 0.0,
sx: tab.sx,
sy: tab.sy,
style: tab.style,
byte: tab.byte,
len: 0,
visible: true,
});
at += adv;
}
n += 1;
}
}
/// Share `extra` points among word spaces, letter gaps and glyph scaling (see [`layout_line`]).
fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &mut [f64], scale: &mut [f64]) {
let stretch = extra >= 0.0;
let word: Vec<f64> = spaces
.iter()
.map(|&i| {
let g = &line[i];
if g.ch != ' ' {
0.0
} else if stretch {
g.space * (sp.word_max - sp.word_desired).max(0.0)
} else {
g.space * (sp.word_desired - sp.word_min).max(0.0)
}
})
.collect();
// Letter gaps: between visible glyphs (not after the line's last glyph).
let last_box = line.iter().rposition(|g| !g.is_space() && g.adv > 0.0).unwrap_or(0);
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_space() && g.adv > 0.0;
let letter: Vec<f64> = line
.iter()
.enumerate()
.map(|(i, g)| {
if !is_box(i, g) {
0.0
} else if stretch {
g.space * (sp.letter_max - sp.letter_desired).max(0.0)
} else {
g.space * (sp.letter_desired - sp.letter_min).max(0.0)
}
})
.collect();
let glyph: Vec<f64> = line
.iter()
.map(|g| {
if g.is_space() || g.adv <= 0.0 {
0.0
} else {
let natural = g.adv / sp.glyph_desired.max(0.01);
natural * if stretch { (sp.glyph_max - sp.glyph_desired).max(0.0) } else { (sp.glyph_desired - sp.glyph_min).max(0.0) }
}
})
.collect();
let (yw, yl, yg) = (word.iter().sum::<f64>(), letter.iter().sum::<f64>(), glyph.iter().sum::<f64>());
let mut rem = extra.abs();
let tw = rem.min(yw);
rem -= tw;
let tl = rem.min(yl);
rem -= tl;
let tg = rem.min(yg);
rem -= tg;
let sign = if stretch { 1.0 } else { -1.0 };
for (k, &i) in spaces.iter().enumerate() {
let share = if yw > 1e-9 { tw * word[k] / yw } else { 0.0 };
// Left over beyond every limit: shared equally by the word spaces.
add[i] += sign * (share + rem / spaces.len() as f64);
}
if yl > 1e-9 && tl > 0.0 {
for (i, l) in letter.iter().enumerate() {
add[i] += sign * tl * l / yl;
}
}
if yg > 1e-9 && tg > 0.0 {
for (i, gcap) in glyph.iter().enumerate() {
if *gcap > 0.0 {
let w = line[i].adv;
scale[i] = (w + sign * tg * gcap / yg) / w;
}
}
}
}
fn place(g: &Glyph, x: f64) -> PlacedGlyph {
let visible = !(g.ch == '\t'
|| g.ch == '\n'
|| breaker::is_forced(g.ch)
|| g.ch == story::INDENT_HERE
|| g.ch == story::RIGHT_INDENT_TAB
|| g.ch == story::TABLE_ANCHOR
|| g.ch == shape::SOFT_HYPHEN);
PlacedGlyph {
face: g.face,
gid: g.gid,
x: x + g.dx,
y: -g.shift + g.dy,
adv: g.adv,
sx: g.sx,
sy: g.sy,
style: g.style,
byte: g.byte,
len: g.len,
visible,
}
}
fn prepend_label(
db: &FontDb,
glyphs: &mut Vec<Glyph>,
label: &str,
at: usize,
base: &designcraft_doc::CharProps,
pp: &ParaProps,
table: &mut StyleTable<'_>,
) {
// Shape the label as a tiny standalone story so it uses the paragraph's base character style.
let mut tmp = Story::new(StoryId(0));
tmp.insert(0, label);
let styles = designcraft_doc::Styles::default();
let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, pp.auto_leading, &SubstCtx::default(), table, &[], &[]);
let mut pre: Vec<Glyph> = shaped
.glyphs
.into_iter()
.map(|mut g| {
g.byte = at;
g.len = 0;
g
})
.collect();
pre.append(glyphs);
*glyphs = pre;
}
type LimitsKey = (usize, usize, usize, bool);
/// Mark glyphs after which a hyphen may be inserted (dictionary/pattern points within the
/// paragraph's limits; words with discretionary hyphens break only there).
fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps) -> Vec<bool> {
let mut out = vec![false; glyphs.len()];
if !pp.hyphenate {
return out;
}
let lim = hyphen::Limits {
min_word: pp.hyph_min_word as usize,
after_first: pp.hyph_after_first as usize,
before_last: pp.hyph_before_last as usize,
capitalized: pp.hyph_capitalized,
};
// Words repeat a lot: remember their points per set of limits.
static CACHE: Mutex<Vec<(LimitsKey, HashMap<Box<str>, Box<[usize]>>)>> = Mutex::new(Vec::new());
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let idx = match guard.iter().position(|e| e.0 == key) {
Some(i) => i,
None => {
if guard.len() > 32 {
guard.clear();
}
guard.push((key, HashMap::new()));
guard.len() - 1
}
};
let cache = &mut guard[idx].1;
if cache.len() > 100_000 {
cache.clear();
}
let mut i = 0;
while i < glyphs.len() {
if !glyphs[i].is_letter() || glyphs[i].len == 0 {
i += 1;
continue;
}
let mut j = i;
while j < glyphs.len() && (glyphs[j].is_letter() || glyphs[j].len == 0 || glyphs[j].ch == shape::SOFT_HYPHEN) && !glyphs[j].no_break {
j += 1;
}
// A discretionary hyphen in a word (or right before it) replaces automatic hyphenation.
let discretionary = glyphs[i..j].iter().any(|g| g.ch == shape::SOFT_HYPHEN) || (i > 0 && glyphs[i - 1].ch == shape::SOFT_HYPHEN);
let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len);
if !discretionary && b > a && b <= text.len() && text.is_char_boundary(a) && text.is_char_boundary(b) {
let word = &text[a..b];
// Do not hyphenate the paragraph's last word unless allowed.
let is_last_word = !pp.hyph_last_word && glyphs[j..].iter().all(|g| !g.is_letter());
if !is_last_word {
let pts = cache.entry(word.into()).or_insert_with(|| hyphen::hyphen_points(word, &lim).into_boxed_slice());
for &p in pts.iter() {
// char index p → byte → glyph whose cluster ends at that byte.
let byte = a + word.char_indices().nth(p).map(|(bi, _)| bi).unwrap_or(word.len());
if let Some(k) = (i..j).find(|&k| glyphs[k].byte + glyphs[k].len == byte && glyphs[k].len > 0) {
out[k] = true;
}
}
}
}
i = j.max(i + 1);
}
out
}
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) {
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
return;
}
// Per column.
let ncols = ft.columns.len().max(1) as u32;
for c in 0..ncols {
let idx: Vec<usize> = ft.lines.iter().enumerate().filter(|(_, l)| l.column == c).map(|(i, _)| i).collect();
let (Some(&a), Some(&z)) = (idx.first(), idx.last()) else { continue };
let bottom = ft.lines[z].baseline + ft.lines[z].descent;
let space = f.area.y1 - bottom;
if space <= 0.0 {
continue;
}
let _ = a;
match f.opts.vertical_justification {
VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0),
VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space),
VerticalJustification::Justify if idx.len() > 1 => {
let per = space / (idx.len() - 1) as f64;
for (k, &i) in idx.iter().enumerate() {
ft.lines[i].baseline += per * k as f64;
}
}
_ => {}
}
}
}
// ---------- caret and hit testing (Type tool) ----------
/// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent).
pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
let mut best: Option<(usize, &Line)> = None;
for (fi, ft) in cs.frames.iter().enumerate() {
for l in &ft.lines {
if pos >= l.range.start && pos <= l.range.end {
// Prefer the line where pos is not at its end (except the last line of a paragraph).
let at_end = pos == l.range.end && !l.last_in_para;
if best.is_none() || !at_end {
best = Some((fi, l));
if !at_end {
break;
}
}
}
}
if best.is_some_and(|(_, l)| pos < l.range.end || l.last_in_para) {
break;
}
}
let (fi, l) = best?;
Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent))
}
fn caret_x(l: &Line, pos: usize) -> f64 {
for g in &l.glyphs {
if g.len > 0 && pos >= g.byte && pos < g.byte + g.len {
// Inside a multi-char cluster (ligature): interpolate.
let t = (pos - g.byte) as f64 / g.len as f64;
return g.x + g.adv * t;
}
if g.len > 0 && g.byte >= pos {
return g.x;
}
}
l.end_x
}
/// Story byte nearest to point `p` (frame inner space) in frame `fi`.
pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
let ft = cs.frames.get(fi)?;
if ft.lines.is_empty() {
return Some(ft.range.start);
}
// Closest line vertically (within its column).
let l = ft
.lines
.iter()
.filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1)
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)))
.or_else(|| ft.lines.first())?;
let mut best = l.range.start;
let mut prev_mid = f64::NEG_INFINITY;
for g in l.glyphs.iter().filter(|g| g.len > 0) {
let mid = g.x + g.adv / 2.0;
if p.x < mid && p.x >= prev_mid {
return Some(g.byte);
}
prev_mid = mid;
best = g.byte + g.len;
}
// After the last glyph: line end (before the paragraph separator / break).
Some(if l.last_in_para { l.range.end } else { best.min(l.range.end) })
}
fn line_dist(l: &Line, y: f64) -> f64 {
let top = l.baseline - l.ascent;
let bot = l.baseline + l.descent;
if y < top {
top - y
} else if y > bot {
y - bot
} else {
0.0
}
}
#[cfg(test)]
mod tests;
/// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space
/// (grouped by run style).
pub fn path_glyphs(
cs: &ComposedStory,
ft: &FrameText,
path: &designcraft_geom::BezPath,
pt: &designcraft_doc::PathType,
) -> Vec<(u32, designcraft_geom::BezPath)> {
let db = designcraft_fonts::FontDb::global();
let warp = designcraft_geom::warp::PathWarp::new(path, pt.flip);
let mut runs: Vec<(u32, designcraft_geom::BezPath)> = Vec::new();
for l in &ft.lines {
// The part of the type that sits on the path.
let on = match pt.align {
designcraft_doc::PathAlign::Baseline => l.baseline,
designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent,
designcraft_doc::PathAlign::Descender => l.baseline + l.descent,
designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0,
};
for g in l.glyphs.iter().filter(|g| g.visible) {
let outline = db.outline(&g.face, g.gid);
if outline.elements().is_empty() {
continue;
}
let style = &cs.styles[g.style as usize];
let mid = g.x + g.adv / 2.0;
let Some((p, a)) = warp.at(pt.start + mid) else { continue };
let place = designcraft_geom::Affine::translate(p.to_vec2())
* designcraft_geom::Affine::rotate(a)
* designcraft_geom::Affine::translate((-mid, -on));
let skew = if style.skew != 0.0 {
designcraft_geom::Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0])
} else {
designcraft_geom::Affine::IDENTITY
};
let m =
place * designcraft_geom::Affine::translate((g.x, l.baseline + g.y)) * skew * designcraft_geom::Affine::scale_non_uniform(g.sx, g.sy);
let bp = match runs.iter_mut().find(|r| r.0 == g.style) {
Some(r) => &mut r.1,
None => {
runs.push((g.style, designcraft_geom::BezPath::new()));
&mut runs.last_mut().expect("pushed").1
}
};
bp.extend((m * outline.as_ref().clone()).elements().iter().copied());
}
}
runs
}