//! 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, /// 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, /// 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, } /// 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 { [(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, } /// Characters set upright (unrotated) in vertical text. pub fn upright_in_vertical(c: char) -> bool { matches!(c as u32, 0x1100..=0x11FF | 0x2E80..=0x2FFF | 0x3040..=0x30FF | 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, pub para: usize, pub glyphs: Vec, 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, pub decos: Vec, /// Byte range of the story shown in this frame. pub range: Range, /// Column rectangles (inner space) for drawing column guides / hit testing. pub columns: Vec, /// 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, /// Footnotes placed at the bottom of this frame's columns. pub notes: Vec, /// Anchored objects placed in this frame's lines. pub objects: Vec, } /// 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, /// The footnote's story text (exporters map glyphs back to text). pub source: Arc, } #[derive(Clone, Debug, Default)] pub struct ComposedStory { pub story: StoryId, pub rev: u64, pub frames: Vec, /// First byte that didn't fit (overset text), if any. pub overset_at: Option, pub styles: Vec, 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, pub page_name: Option, /// Absolute document page the frame is on (None on parent pages). pub page: Option, /// 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 { 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, /// Absolute page for text variables when composed for a specific page (parent items). pub page: Option, /// Running-header index (needed when the story contains running-header variables). pub running: Option>, /// Generated text before the first paragraph (a footnote's number and separator). pub label: Option, /// Anchor pages for cross-references (needed when the story contains cross-references). pub xrefs: Option>, } /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { 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 (area, exclusions, grid, page_rect) = if tf.options.vertical && tf.options.path.is_none() { 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, 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 { 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 = Vec::new(); let mut missing_fonts: HashMap = HashMap::new(); out.frames = frames.iter().map(|f| FrameText { frame: f.id, vertical: f.opts.vertical, columns: f.columns(), ..Default::default() }).collect(); let cols: Vec> = 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 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> = { let mut counters: std::collections::HashMap = 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 = Vec::with_capacity(np); let mut info: Vec = Vec::with_capacity(np); let mut force_col = vec![false; np]; let mut line_cap: Vec> = 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)> = Vec::with_capacity(np); let mut var_cache: std::collections::HashMap, std::sync::Arc>> = 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(), ..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 }; 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, 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); 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, ); } } 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, &pp, &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, &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, &hyph_exceptions); 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()) }; 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 = 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, 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_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 = 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> = 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 = 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 = 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, } } /// 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]) -> Option { // 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 { 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, len: usize) -> usize { overset.unwrap_or(len) } #[derive(Clone)] struct Cursor { fi: usize, col: usize, last_baseline: Option, 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]) { 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 } /// 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, ) -> (Vec, f64, f64) { let mut line: Vec = 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 = 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; let rtl_para = pp.direction == designcraft_doc::TextDirection::RightToLeft; let bidi = (rtl_para || line.iter().any(|g| designcraft_fonts::is_rtl(g.ch))) && leaders.is_empty(); 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]; 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); } } if bidi { pens.push(x); reorder_visual(&line, &mut out, &pens, x0 + offset, rtl_para); } (out, x, ratio) } /// 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, rtl: bool) { use unicode_bidi::{BidiInfo, Level}; // Clusters: runs of glyphs from the same source character. let mut units: Vec> = Vec::new(); for i in 0..line.len() { match units.last_mut() { Some(u) if line[u.start].byte == line[i].byte && line[i].len > 0 => u.end = i + 1, _ => units.push(i..i + 1), } } let text: String = units.iter().map(|u| line[u.start].ch).collect(); let info = BidiInfo::new(&text, Some(if rtl { Level::rtl() } else { Level::ltr() })); let levels: Vec = text.char_indices().map(|(b, _)| info.levels[b]).collect(); let order = BidiInfo::reorder_visual(&levels); let width = |u: &std::ops::Range| 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]; } 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) { 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, }); 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 = 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 = 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 = 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::(), letter.iter().sum::(), glyph.iter().sum::()); 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: upright_in_vertical(g.ch), } } fn prepend_label( db: &FontDb, glyphs: &mut Vec, 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, shape::TypeEnv { auto_leading: pp.auto_leading, adv: Default::default() }, &SubstCtx::default(), table, &[], &[], &[], ); let mut pre: Vec = 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, exceptions: &HashMap>) -> Vec { 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, 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 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 = 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 { 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 }