Files
designcraft/crates/compose/src/lib.rs
T

2320 lines
96 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).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bidi;
pub mod breaker;
mod cache;
pub mod hyphen;
mod notes;
mod overlay;
mod ruby;
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, ScopedFonts};
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,
/// Tracking or manual kerning (Highlight Custom Tracking/Kerning).
pub custom_tracking: bool,
/// The first condition applied (its indicator colour underlines the text on screen).
pub condition: Option<String>,
/// Added while tracking changes (marked on screen).
pub inserted: bool,
/// XML element the text is tagged with (tag markers on screen).
pub xml_tag: Option<String>,
/// Ruby over the run, and kenten (emphasis dots).
pub ruby: Option<String>,
pub kenten: bool,
pub kenten_character: String,
}
/// 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,
/// Stays upright in vertical frames (CJK ideographs, kana, hangul, full-width forms).
pub upright: bool,
/// Tate-chu-yoko placement (see [`shape::Glyph::tcy`]).
pub tcy: Option<[f64; 3]>,
/// Set right to left (an odd bidi level): the caret before it is at its right edge.
pub rtl: bool,
}
impl PlacedGlyph {
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
/// group sits across that centre.
pub fn vertical_xf(&self, baseline: f64) -> Option<designcraft_geom::Affine> {
let turn = -std::f64::consts::FRAC_PI_2;
// The em box centre above the baseline, in ems.
let (top, bottom) = self.face.em_box();
let centre = (top + bottom) / 2.0 / self.face.units_per_em();
if let Some([along, across, em]) = self.tcy {
let c = Point::new(self.x + along, baseline + self.y - em * centre);
return Some(designcraft_geom::Affine::rotate_about(turn, c) * designcraft_geom::Affine::translate((c.x + across - self.x, 0.0)));
}
if !self.upright {
return None;
}
// Turning about c takes the glyph's vertical origin (half across, `origin` up) to the
// centre point at `x`: c is where the two points' perpendicular bisector meets the turn.
let half = self.face.advance(self.gid) / 2.0 * self.sx;
let origin = self.face.v_origin(self.gid) * self.sy;
let mid = centre * self.face.units_per_em() * self.sy;
let c = Point::new(self.x + (origin - mid + half) / 2.0, baseline + self.y + (half - mid - origin) / 2.0);
Some(designcraft_geom::Affine::rotate_about(turn, c))
}
}
/// Characters set upright (unrotated) in vertical text.
pub fn upright_in_vertical(c: char) -> bool {
matches!(c as u32,
0x1100..=0x11FF | 0x2E80..=0x2FFF | 0x3000..=0x30FF | 0xFE10..=0xFE4F | 0x3100..=0x31FF | 0x3200..=0x9FFF
| 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFF01..=0xFF60 | 0xFFE0..=0xFFE6
| 0x20000..=0x3FFFF)
}
#[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,
/// H&J violation: 0 = within the paragraph's minimum/maximum word spacing, 1–3 = how far out
/// (Preferences › Composition › Highlight H&J Violations shades).
pub hj: u8,
/// The paragraph's Keep Options couldn't be honoured here (Highlight Keep Violations).
pub keep_violation: bool,
}
/// 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,
/// Vertical Type frame (upright glyphs turn back in it).
pub vertical: bool,
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,
/// Lines run top to bottom and follow each other right to left (the story is vertical); the
/// area is the turned box.
pub vertical: bool,
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,
/// The page and its margins in the frame's inner space (custom anchored objects).
pub page_rect: Option<(Rect, Rect)>,
}
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),
};
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (loc.spread, spread) {
(_, Some(sp)) => sp
.page_at_x(item.bounds().center().x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
};
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()),
};
// Vertical type: composed in the turned box; wraps and page rects turned with it.
let vertical = doc.frame_vertical(item);
let (area, exclusions, grid, page_rect) = if vertical {
let v = designcraft_doc::vertical_text_xf(area).inverse();
let ex = exclusions.into_iter().map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), ..e }).collect();
let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b)));
(Rect::new(0.0, 0.0, area.height(), area.width()), ex, None, pr)
} else {
(area, exclusions, grid, page_rect)
};
out.push(FrameSpec {
id: fid,
area,
opts,
vertical,
exclusions: if tf.options.path.is_some() { vec![] } else { exclusions },
page_name,
page,
grid,
left_page,
page_rect,
});
}
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 {
compose_with_db(doc, story, frames, opts, FontDb::global())
}
fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions, db: &FontDb) -> ComposedStory {
let db = &db.scoped(doc.font_scope);
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();
let cols: Vec<Vec<Rect>> = frames
.iter()
.map(|f| {
let mut columns = f.columns();
if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.vertical {
columns.reverse();
}
columns
})
.collect();
out.frames = frames
.iter()
.zip(&cols)
.map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() })
.collect();
let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 };
let para_ranges = story.para_ranges();
let np = para_ranges.len();
let hyph_exceptions = doc.hyphenation_exception_map();
let mut list_counter: u32 = 0;
// Named lists number independently of layout: one pass up front.
let named_numbers: Vec<Option<u32>> = {
let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
story
.paras
.iter()
.map(|p| {
let (pp, _) = doc.styles.resolve_para(p);
if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() {
return None;
}
let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name));
*c = pp.start_at.map_or(*c + 1, |s| s.max(1));
Some(*c)
})
.collect()
};
// 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,
hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(),
vertical: cur_frame.is_some_and(|f| f.vertical),
..Default::default()
};
if !story.endnotes.is_empty() {
// Endnote reference numbers continue through the document.
let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32;
for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() {
sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32));
}
}
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, custom) = match &o.position {
designcraft_doc::AnchorPosition::Inline { y_offset } => (Some(*y_offset), 0.0, false),
designcraft_doc::AnchorPosition::AboveLine { space_before, space_after, .. } => (None, space_before + space_after, false),
designcraft_doc::AnchorPosition::Custom { .. } => (Some(0.0), 0.0, true),
};
sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space, custom });
}
}
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 env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback };
let mut sp = shape::shape_para(
db,
&doc.styles,
story,
pi,
prange.clone(),
&base_chars,
env,
&sub,
&mut table,
&pp.nested_styles,
&pp.grep_styles,
&[],
);
if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() {
// Nested line styles: find where the first lines end in this column, restyle them, and
// look again (the style changes the widths) until the lines settle.
let cols_here = &cols[cur.fi];
let col = cols_here[cur.col.min(cols_here.len() - 1)];
let spacing = spacing_for(&pp, base_chars.size);
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for _ in 0..3 {
let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0);
// The same spacing, hyphenation and breaker as the layout below.
let mut gl = sp.glyphs.clone();
apply_desired_spacing(&mut gl, &pp);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
breaker::greedy(&gl, &hy, &spacing, &width)
} else if pp.balance_ragged && !spacing.justify {
breaker::balanced(&gl, &hy, &spacing, &width)
} else {
breaker::knuth_plass(&gl, &hy, &spacing, &width)
};
// Byte where line `k` starts (the paragraph end past the last line).
let byte_at =
|k: usize| if k == 0 { prange.start } else { breaks.get(k - 1).and_then(|b| gl.get(b.next)).map_or(prange.end, |g| g.byte) };
let mut want = Vec::new();
let mut line = 0usize;
for nl in &pp.nested_line_styles {
if line >= breaks.len() || nl.lines == 0 {
break;
}
let end = line + nl.lines as usize;
if !nl.style.is_empty() && nl.style != designcraft_doc::NO_CHAR_STYLE {
want.push((byte_at(line)..byte_at(end).max(byte_at(line)), nl.style.clone()));
}
line = end;
}
if want == lines {
break;
}
lines = want;
sp = shape::shape_para(
db,
&doc.styles,
story,
pi,
prange.clone(),
&base_chars,
env,
&sub,
&mut table,
&pp.nested_styles,
&pp.grep_styles,
&lines,
);
}
}
// List labels take the default super/subscript settings.
let label_env = shape::TypeEnv { adv: Default::default(), ..env };
match pp.list_type {
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
// A named list: carries on past other paragraphs (and from earlier stories).
let n = named_numbers.get(pi).copied().flatten().unwrap_or(1);
let label = format!("{}.{}", pp.number_style.format(n), pp.list_separator);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
}
designcraft_doc::ListType::Numbers => {
list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1));
let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &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, label_env, &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, label_env, &mut table);
}
let mut glyphs = sp.glyphs;
let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text);
let bidi_info = unicode_bidi::BidiInfo::new(
&bidi_text,
Some(if pp.direction == designcraft_doc::TextDirection::RightToLeft { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }),
);
bidi::resolve_mirroring(&mut glyphs, &bidi_info);
apply_desired_spacing(&mut glyphs, &pp);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
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));
// The part of the paragraph that fits keeps its shading and border.
para_box_decos(&mut out, pi, &pp);
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()) };
let other = cols[cur.fi][n - 1];
Rect::new(col.x0.min(other.x0), col.y0, col.x1.max(other.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 reference = cjk_line_reference(line_glyphs);
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
// 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 (mut placed, end_x, ratio) =
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info);
ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback);
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,
hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max),
keep_violation: false,
});
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_reference = reference;
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,
});
}
}
para_box_decos(&mut out, pi, &pp);
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)
}
designcraft_doc::AnchorPosition::Custom {
x_relative,
y_relative,
x_offset,
y_offset,
object_point,
ref_point,
keep_within_column,
} => {
use designcraft_doc::anchored::AnchorRelative as R;
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
let area = |r: R| match r {
R::Anchor => None,
R::TextFrame => Some(f.area),
R::ColumnEdge => Some(col),
R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)),
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
};
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
let (rfx, rfy) = frac(*ref_point);
let (ofx, ofy) = frac(*object_point);
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
let mut y = ry + y_offset - h * ofy;
if *keep_within_column {
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
}
Point::new(rx + x_offset - w * ofx, y)
}
};
}
}
}
// 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;
mark_keep_violations(doc, story, &mut out);
out
}
/// Flag the lines of paragraphs whose Keep Options the layout couldn't honour: lines kept
/// together that split, too few lines at the bottom (orphans) or top (widows) of a column, or a
/// keep-with-next paragraph separated from the next one.
fn mark_keep_violations(doc: &Document, story: &Story, out: &mut ComposedStory) {
// Per paragraph, the columns its lines sit in: ((frame, column), line count) in order.
let mut cols: HashMap<usize, Vec<((usize, u32), usize)>> = HashMap::new();
for (fi, ft) in out.frames.iter().enumerate() {
for l in &ft.lines {
let v = cols.entry(l.para).or_default();
match v.last_mut() {
Some((c, n)) if *c == (fi, l.column) => *n += 1,
_ => v.push(((fi, l.column), 1)),
}
}
}
let mut bad: Vec<usize> = Vec::new();
for (&pi, v) in &cols {
let Some(pf) = story.paras.get(pi) else { continue };
let (pp, _) = doc.styles.resolve_para(pf);
if pp.keep_lines_together && v.len() > 1 {
let (first, last) = (v[0].1, v[v.len() - 1].1);
if pp.keep_all_lines || first < pp.keep_first.max(1) as usize || last < pp.keep_last.max(1) as usize {
bad.push(pi);
continue;
}
}
if pp.keep_with_next > 0
&& let (Some(end), Some(next)) = (v.last(), cols.get(&(pi + 1)))
&& next.first().is_some_and(|(c, n)| *c != end.0 || (*n < pp.keep_with_next as usize && next.len() > 1))
{
bad.push(pi);
}
}
if bad.is_empty() {
return;
}
for ft in &mut out.frames {
for l in &mut ft.lines {
if bad.contains(&l.para) {
l.keep_violation = true;
}
}
}
}
/// Paragraph shading and border, per column the paragraph sits in (a split paragraph gets one box
/// per part; the border's top edge on the first part, its bottom edge on the last).
fn para_box_decos(out: &mut ComposedStory, pi: usize, pp: &ParaProps) {
if !pp.shading_on && !pp.border_on {
return;
}
let mut parts: Vec<(usize, Rect)> = Vec::new();
for (fi, ft) in out.frames.iter().enumerate() {
let mut cols_seen: Vec<u32> = ft.lines.iter().filter(|l| l.para == pi).map(|l| l.column).collect();
cols_seen.dedup();
for c in cols_seen {
let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi && l.column == c).collect();
if let (Some(a), Some(z)) = (lines.first(), lines.last()) {
parts.push((fi, Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent)));
}
}
}
let n = parts.len();
for (k, (fi, r)) in parts.into_iter().enumerate() {
let ft = &mut out.frames[fi];
if pp.shading_on {
let [t, l, b, rr] = pp.shading_offsets;
ft.decos
.insert(0, Deco { rect: Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + b), color: pp.shading_color.clone(), tint: pp.shading_tint });
}
if pp.border_on {
let [t, l, b, rr] = pp.border_offsets;
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + b);
let [wt, wl, wb, wr] = pp.border_weights.map(|w| w.max(0.0));
let mut edge = |rect: Rect| ft.decos.push(Deco { rect, color: pp.border_color.clone(), tint: pp.border_tint });
if k == 0 && wt > 0.0 {
edge(Rect::new(o.x0 - wl, o.y0 - wt, o.x1 + wr, o.y0));
}
if k + 1 == n && wb > 0.0 {
edge(Rect::new(o.x0 - wl, o.y1, o.x1 + wr, o.y1 + wb));
}
if wl > 0.0 {
edge(Rect::new(o.x0 - wl, o.y0, o.x0, o.y1));
}
if wr > 0.0 {
edge(Rect::new(o.x1, o.y0, o.x1 + wr, o.y1));
}
}
}
}
/// 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,
korean_char_breaks: pp.korean_char_breaks,
}
}
/// Desired word spacing, letter spacing and glyph scaling apply to every line (any alignment).
fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
for i in 0..glyphs.len() {
let (left, right) = glyphs.split_at_mut(i + 1);
let Some(g) = left.last_mut() else { continue };
if pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None && pp.kinsoku.as_ref().is_some_and(|k| k.hanging.contains(g.ch)) {
g.cjk_hang = g.adv;
}
g.ideographic_space_elastic = pp.treat_ideographic_space_as_space;
if g.ch == '\u{3000}' && pp.treat_ideographic_space_as_space {
g.space = g.adv;
}
if let Some(next) = right.first() {
let a = g.ch;
let b = next.ch;
// A kinsoku set rules where CJK text may break; Korean still breaks at spaces.
let cjk = breaker::cjk_pair(g, next, pp.korean_char_breaks);
if g.break_after != Some(false) {
if let Some(set) = &pp.kinsoku {
if !set.allows(a, b) {
g.break_after = Some(false);
} else if cjk {
g.break_after = Some(true);
}
}
if (pp.bunri_kinshi && a == b && matches!(a, '.' | '-' | '…' | '‥' | '—' | '―')) || (pp.rensuuji && a.is_numeric() && b.is_numeric())
{
g.break_after = Some(false);
}
}
}
}
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.locked_advance {
continue;
}
if g.ch == ' ' || (g.ch == '\u{3000}' && g.ideographic_space_elastic) {
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,
last_reference: 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)
}
}
}
}
/// Em box relative to the baseline. Font ascender/descender proportions locate it;
/// the box's total extent is the em, rather than the font's optional line gap.
fn cjk_em_box(g: &Glyph) -> (f64, f64) {
let height = g.face.units_per_em() * g.sy.abs();
let top = -height * g.ascent / (g.ascent + g.descent).max(1e-9);
(top, top + height)
}
fn cjk_alignment_shift(g: &Glyph, reference: &Glyph) -> f64 {
use designcraft_doc::cjk::CharacterAlignment as A;
let (t, b) = cjk_em_box(g);
let (rt, rb) = cjk_em_box(reference);
match g.character_alignment {
A::Baseline => 0.0,
A::EmTop => t - rt,
A::EmCenter => (t + b - rt - rb) / 2.0,
A::EmBottom => b - rb,
A::IcfTop => reference.ascent - g.ascent,
A::IcfBottom => g.descent - reference.descent,
}
}
fn cjk_line_reference(line: &[Glyph]) -> f64 {
use designcraft_doc::cjk::LeadingModel as L;
let Some(g) = line.iter().max_by(|a, b| a.size.total_cmp(&b.size)) else { return 0.0 };
let (top, bottom) = cjk_em_box(g);
match g.leading_model {
L::Roman => 0.0,
L::AkiBelow => bottom,
L::AkiAbove => top,
L::Center | L::CenterDown => (top + bottom) / 2.0,
}
}
fn line_metrics(
line: &[Glyph],
all: &[Glyph],
s: usize,
base_leading: f64,
base_size: f64,
db: &ScopedFonts<'_>,
base: &designcraft_doc::CharProps,
) -> (f64, f64, f64) {
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 = db.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;
let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size));
for g in src {
let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r));
asc = asc.max(g.ascent + shift.max(0.0));
desc = desc.max(g.descent - 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
}
/// How far a line's word spacing falls outside `[min, max]` (ratios of the space width): 0 inside,
/// then 1–3 for up to 10%, 25% and beyond.
pub fn hj_severity(ratio: f64, min: f64, max: f64) -> u8 {
let r = if ratio > max + 1e-6 {
ratio / max.max(1e-6)
} else if ratio < min - 1e-6 {
min / ratio.max(1e-6)
} else {
return 0;
};
if r < 1.1 {
1
} else if r < 1.25 {
2
} else {
3
}
}
/// 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,
bidi_info: &unicode_bidi::BidiInfo<'_>,
) -> (Vec<PlacedGlyph>, f64, f64) {
let mut line: Vec<Glyph> = glyphs[s..e.max(s)].to_vec();
let reference = line.iter().max_by(|a, b| a.size.total_cmp(&b.size)).cloned();
if let Some(reference) = reference {
for g in &mut line {
g.shift += cjk_alignment_shift(g, &reference);
}
}
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 || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !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 = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 };
let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 };
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular {
cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0))
} else {
cjk_right
};
let hr = optical_right.max(cjk_right);
x0 -= hl;
measure += hl + hr;
}
let natural: f64 = line.iter().map(|g| g.adv).sum();
let mut 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;
// Kashidas: in justified Arabic, the joins of words take the extra length first.
let mut kashidas: Vec<(usize, f64)> = Vec::new();
if justify_this && extra > 0.0 && pp.kashidas && pp.arabic_justification != "DefaultJustification" {
let points = kashida_points(&line);
if !points.is_empty() {
let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum());
let per = total / points.len() as f64;
kashidas = points.into_iter().map(|i| (i, per)).collect();
extra -= total;
for &(i, l) in &kashidas {
add[i] += l;
}
}
}
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 gaps: Vec<_> = line
.iter()
.enumerate()
.take(line.len() - 1)
.filter(|(_, g)| !g.locked_advance || g.break_after != Some(false))
.map(|(i, _)| i)
.collect();
if !gaps.is_empty() {
let per = extra / gaps.len() as f64;
for i in gaps {
add[i] = 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;
let bidi = bidi_info.has_rtl();
let mut pens = Vec::with_capacity(if bidi { line.len() } else { 0 });
for (i, g) in line.iter().enumerate() {
if bidi {
pens.push(x);
}
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];
if let Some((_, l)) = kashidas.iter().find(|k| line[k.0].byte == g.byte) {
// Move the whole cluster, including its attached marks, past the elongation.
p.x += l;
}
x += p.adv;
if let Some((_, l)) = kashidas.iter().find(|k| k.0 == i) {
// The glyph keeps its own width; the gap before it is the tatweel's.
p.adv -= l;
}
out.push(p);
}
if bidi {
pens.push(x);
reorder_visual(&line, &mut out, &pens, x0 + offset, bidi_info);
}
for (i, leader) in &leaders {
if let Some((g, p)) = line.get(*i).zip(out.get(*i)) {
tab_leader(g, leader, p.x, p.adv, tab_origin, &mut out);
}
}
for (i, l) in kashidas {
let first = (0..=i).rev().take_while(|&j| line[j].byte == line[i].byte).last().unwrap_or(i);
let (g, at) = (&line[i], out[first].x - line[first].dx - l);
let gid = designcraft_fonts::first_glyph(&g.face, &['\u{0640}']);
let w = g.face.advance(gid) * g.sx;
if gid == 0 || w <= 0.0 {
continue;
}
// One tatweel stretched over the gap (overlapping its neighbours a little).
let k = (l + 0.4) / w;
out.push(PlacedGlyph { gid, x: at - 0.2, y: -g.shift, adv: 0.0, sx: g.sx * k, len: 0, upright: false, tcy: None, ..place(g, at) });
}
(out, x, ratio)
}
/// Arabic letters that join the following letter (dual-joining).
fn joins_next(c: char) -> bool {
matches!(c as u32,
0x0626 | 0x0628 | 0x062A..=0x062E | 0x0633..=0x063F | 0x0641..=0x0647 | 0x0649 | 0x064A | 0x066E | 0x066F
| 0x0678..=0x0687 | 0x069A..=0x06BF | 0x06C1 | 0x06CC | 0x06CE | 0x06D0 | 0x06D1 | 0x06FA..=0x06FC)
}
/// Arabic letters that join the preceding one (dual- or right-joining).
fn joins_prev(c: char) -> bool {
joins_next(c)
|| matches!(c as u32,
0x0622..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699
| 0x06C0 | 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3)
}
/// Where kashidas go on a line: one join per word, after a seen or sad when there is one, else the
/// word's last join (indices of the glyph before the join).
fn kashida_points(line: &[Glyph]) -> Vec<usize> {
let mut out = Vec::new();
let mut best: Option<(bool, usize)> = None;
for (i, g) in line.iter().enumerate() {
if g.is_space() {
if let Some((_, k)) = best.take() {
out.push(k);
}
continue;
}
// Only consider boundaries between complete clusters. Marks with len=0
// are part of their base cluster, not word boundaries or insertion sites.
let Some(n) = line.get(i + 1) else { continue };
if n.byte == g.byte || !g.allow_kashidas || !n.allow_kashidas || !n.safe_tatweel_before {
continue;
}
if g.face.id() != n.face.id() || g.face.glyph_for('\u{0640}') == 0 {
continue;
}
if !joins_next(g.ch) || !joins_prev(n.ch) || (g.ch == '\u{0644}' && matches!(n.ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')) {
continue;
}
let seen = matches!(g.ch as u32, 0x0633..=0x0636);
if best.is_none_or(|(s, _)| !s || seen) {
best = Some((seen, i));
}
}
out.extend(best.map(|b| b.1));
out
}
/// Bidi: put a laid-out line (glyphs in text order, `pens` their pen positions and then the end
/// position) into visual order from `start`, cluster by cluster (Unicode Bidirectional
/// Algorithm, rule L2).
fn reorder_visual(line: &[Glyph], out: &mut [PlacedGlyph], pens: &[f64], start: f64, info: &unicode_bidi::BidiInfo<'_>) {
use unicode_bidi::{BidiInfo, Level};
// Clusters: runs of glyphs from the same source character.
let mut units: Vec<std::ops::Range<usize>> = Vec::new();
for i in 0..line.len() {
match units.last_mut() {
Some(u) if line[u.start].bidi_offset == line[i].bidi_offset && line[u.start].byte == line[i].byte => u.end = i + 1,
_ => units.push(i..i + 1),
}
}
let Some(first) = line.first() else { return };
let Some(para) = info.paragraphs.iter().find(|p| p.range.contains(&first.bidi_offset)) else { return };
let end = line.iter().map(|g| g.bidi_end).max().unwrap_or(first.bidi_end).min(para.range.end);
let resolved = info.reordered_levels(para, first.bidi_offset..end);
let levels: Vec<Level> = units.iter().map(|u| resolved.get(line[u.start].bidi_offset).copied().unwrap_or(para.level)).collect();
let order = BidiInfo::reorder_visual(&levels);
let width = |u: &std::ops::Range<usize>| pens[u.end] - pens[u.start];
let mut cur = start;
let mut shifts = vec![0.0; line.len()];
for &k in &order {
let u = &units[k];
for i in u.clone() {
shifts[i] = cur - pens[u.start];
out[i].rtl = levels[k].is_rtl();
}
cur += width(u);
}
for (p, d) in out.iter_mut().zip(shifts) {
p.x += d;
}
}
/// 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,
upright: false,
tcy: None,
rtl: false,
});
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 != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
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 && (!g.locked_advance || g.break_after != Some(false));
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 || g.locked_advance {
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
|| g.ch == shape::HIDDEN);
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,
upright: g.upright,
tcy: g.tcy,
rtl: false,
}
}
fn prepend_label(
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
label: &str,
at: usize,
base: &designcraft_doc::CharProps,
env: shape::TypeEnv,
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, env, &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).
/// Whether a language (a name or locale code, see [`designcraft_doc::language_tag`]) uses the
/// English hyphenation and spelling dictionaries.
pub fn is_english(language: &str) -> bool {
// InDesign's English names first: composition asks this for every run.
language.starts_with("English") || designcraft_doc::language_tag(language).is_some_and(|t| designcraft_doc::language_subtag(t) == "en")
}
/// Byte ranges of paragraph `prange` set in a language other than English (no English
/// hyphenation there).
fn foreign_ranges(doc: &Document, story: &designcraft_doc::Story, prange: Range<usize>, base: &designcraft_doc::CharProps) -> Vec<Range<usize>> {
if is_english(&base.language)
&& story.runs().all(|(_, f)| f.over.language.as_deref().is_none_or(is_english) && f.style == designcraft_doc::story::NO_CHAR_STYLE)
{
return vec![];
}
story
.runs()
.filter(|(r, _)| r.start < prange.end && r.end > prange.start)
.filter(|(_, f)| !is_english(&doc.styles.resolve_char(base, f).language))
.map(|(r, _)| r)
.collect()
}
fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: &HashMap<String, Vec<usize>>, foreign: &[Range<usize>]) -> 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 no-break letter can't start a hyphenatable word.
if j == i {
i += 1;
continue;
}
// 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());
let in_foreign = foreign.iter().any(|r| r.contains(&a));
if !is_last_word && !in_foreign {
let user = (!exceptions.is_empty()).then(|| exceptions.get(&word.to_lowercase())).flatten();
let pts: &[usize] = match user {
// User dictionary exceptions win over the patterns and the built-in list.
Some(v) => v,
None => 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 (fi, l) = caret_line(cs, pos)?;
Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent))
}
/// The line the caret at `pos` is drawn on (and its frame index).
fn caret_line(cs: &ComposedStory, pos: usize) -> Option<(usize, &Line)> {
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;
}
}
best
}
/// The caret's x on line `l` for story byte `pos` (bidi-aware).
pub 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 if g.rtl { g.x + g.adv * (1.0 - t) } else { g.x + g.adv * t };
}
if g.len > 0 && g.byte >= pos {
return if g.rtl { g.x + g.adv } else { g.x };
}
}
match l.glyphs.iter().rev().find(|g| g.len > 0) {
Some(g) if g.rtl => g.x,
_ => l.end_x,
}
}
/// Caret positions on a line with their x, for lines with right-to-left text.
fn caret_stops(l: &Line) -> Vec<(usize, f64)> {
let mut ps: Vec<usize> = l.glyphs.iter().filter(|g| g.len > 0).flat_map(|g| [g.byte, g.byte + g.len]).collect();
ps.extend([l.range.start, l.range.end]);
ps.sort_unstable();
ps.dedup();
ps.into_iter().filter(|p| l.range.contains(p) || *p == l.range.end).map(|p| (p, caret_x(l, p))).collect()
}
/// Bidi caret movement: the position one step to the left (or right) of `pos` on its line as
/// drawn. `None` when the line has no right-to-left text or `pos` is at that side's edge of the
/// line: then move in text order ([`line_rtl`] says which way that runs).
pub fn visual_step(cs: &ComposedStory, pos: usize, left: bool) -> Option<usize> {
let (_, l) = caret_line(cs, pos)?;
if !l.glyphs.iter().any(|g| g.rtl) {
return None;
}
let x = caret_x(l, pos);
let stops = caret_stops(l);
let side = |s: &&(usize, f64)| if left { s.1 < x - 0.01 } else { s.1 > x + 0.01 };
let pick = stops.iter().filter(side);
if left { pick.max_by(|a, b| a.1.total_cmp(&b.1)) } else { pick.min_by(|a, b| a.1.total_cmp(&b.1)) }.map(|s| s.0)
}
/// Does the line at `pos` start (in text order) with right-to-left text?
pub fn line_rtl(cs: &ComposedStory, pos: usize) -> bool {
caret_line(cs, pos).and_then(|(_, l)| l.glyphs.iter().find(|g| g.len > 0)).is_some_and(|g| g.rtl)
}
/// 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())?;
if l.glyphs.iter().any(|g| g.rtl) {
// Bidi: the caret position drawn nearest the point.
return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0);
}
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 k = match runs.iter().position(|r| r.0 == g.style) {
Some(k) => k,
None => {
runs.push((g.style, designcraft_geom::BezPath::new()));
runs.len() - 1
}
};
let bp = &mut runs[k].1;
bp.extend((m * outline.as_ref().clone()).elements().iter().copied());
}
}
runs
}