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