continued work

This commit is contained in:
Brandon Thomas 2026-10-01 09:36:32 -04:00
parent e1e1352fff
commit c9dcad1f6e
31 changed files with 7217 additions and 3 deletions

1051
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
members = ["crates/*", "xtask"]
default-members = ["crates/*", "xtask"]
[workspace.package]
version = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

19
crates/compose/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "designcraft-compose"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft text composition: paragraph composer (Knuth–Plass), hyphenation, justification, columns, threading, wrap"
[lints]
workspace = true
[dependencies]
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
designcraft-fonts = { workspace = true }
log = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

View File

@ -0,0 +1,390 @@
//! Line breaking: the paragraph composer (Knuth–Plass total fit) and the single-line composer
//! (greedy first fit). Both work on a paragraph's glyphs and a per-line measure function.
use crate::shape::{Glyph, SOFT_HYPHEN};
/// One line chosen by a breaker: glyphs `start..end` are visible; `next` is where the next line starts.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Break {
pub start: usize,
pub end: usize,
pub next: usize,
/// Broken at a hyphenation point (a hyphen glyph must be added).
pub hyphen: bool,
/// Ended by a forced break (paragraph end, forced line break, column/frame/page break).
pub forced: bool,
}
/// Spacing parameters (from the paragraph's Justification settings), as fractions.
#[derive(Clone, Copy, Debug)]
pub struct Spacing {
pub justify: bool,
pub word_min: f64,
pub word_desired: f64,
pub word_max: f64,
/// Penalty for a hyphenated break (0 = free, larger = fewer hyphens).
pub hyphen_penalty: f64,
/// Maximum consecutive hyphenated lines (0 = unlimited).
pub hyphen_limit: u32,
/// Ragged-right stretchability (points) for unjustified paragraphs.
pub ragged_stretch: f64,
}
#[derive(Clone, Copy, Debug)]
enum Item {
/// Unbreakable width (a glyph).
Box { w: f64 },
/// Space glyph `g`.
Glue { w: f64, st: f64, sh: f64 },
Penalty { w: f64, p: f64, flagged: bool },
}
const INF: f64 = 10000.0;
/// Does the glyph force a line end after it?
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK)
}
/// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens).
fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
let mut it = Vec::with_capacity(glyphs.len() * 2 + 2);
let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2);
let n = glyphs.len();
for (i, g) in glyphs.iter().enumerate() {
if is_forced(g.ch) {
if !sp.justify || true {
it.push(Item::Glue { w: 0.0, st: INF, sh: 0.0 });
ig.push(i);
}
it.push(Item::Penalty { w: 0.0, p: -INF, flagged: false });
ig.push(i + 1);
continue;
}
if g.is_space() && !g.no_break {
let w = g.adv;
if sp.justify {
let base = w / sp.word_desired.max(1e-6);
it.push(Item::Glue { w, st: base * (sp.word_max - sp.word_desired).max(0.0), sh: base * (sp.word_desired - sp.word_min).max(0.0) });
ig.push(i);
} else {
// Ragged right (Knuth): glue(0, s, 0) penalty(0) glue(w, -s, 0).
it.push(Item::Glue { w: 0.0, st: sp.ragged_stretch, sh: 0.0 });
ig.push(i);
it.push(Item::Penalty { w: 0.0, p: 0.0, flagged: false });
ig.push(i);
it.push(Item::Glue { w, st: -sp.ragged_stretch, sh: 0.0 });
ig.push(i);
}
continue;
}
if g.ch == SOFT_HYPHEN {
let hw = g.size * 0.33;
it.push(Item::Penalty { w: hw, p: sp.hyphen_penalty, flagged: true });
ig.push(i + 1);
continue;
}
it.push(Item::Box { w: g.adv });
ig.push(i);
let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space());
if i + 1 < n && !next_is_space && !g.no_break {
if matches!(g.ch, '-' | '\u{2010}') {
it.push(Item::Penalty { w: 0.0, p: 50.0, flagged: true });
ig.push(i + 1);
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') {
it.push(Item::Penalty { w: 0.0, p: 0.0, flagged: false });
ig.push(i + 1);
} else if hyph_after[i] {
let hw = g.size * 0.33;
it.push(Item::Penalty { w: hw, p: sp.hyphen_penalty, flagged: true });
ig.push(i + 1);
}
}
}
// Paragraph end: fill glue + forced break.
it.push(Item::Glue { w: 0.0, st: INF, sh: 0.0 });
ig.push(n);
it.push(Item::Penalty { w: 0.0, p: -INF, flagged: false });
ig.push(n);
(it, ig)
}
#[derive(Clone, Copy, Debug)]
struct Node {
pos: usize,
line: usize,
fitness: u8,
tw: f64,
ty: f64,
tz: f64,
demerits: f64,
prev: Option<usize>,
hyphens: u32,
flagged: bool,
}
/// Knuth–Plass total-fit line breaking. `width(line)` gives each line's measure.
pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
if glyphs.is_empty() {
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
}
let (items, ig) = items(glyphs, hyph_after, sp);
for tolerance in [3.0, 30.0, f64::INFINITY] {
if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) {
return b;
}
}
greedy(glyphs, hyph_after, sp, width)
}
#[allow(clippy::too_many_arguments)]
fn kp_pass(items: &[Item], ig: &[usize], glyphs: &[Glyph], sp: &Spacing, width: &dyn Fn(usize) -> f64, tol: f64, emergency: bool) -> Option<Vec<Break>> {
let m = items.len();
// Prefix sums (before item i).
let mut sw = vec![0.0; m + 1];
let mut sy = vec![0.0; m + 1];
let mut sz = vec![0.0; m + 1];
for (i, it) in items.iter().enumerate() {
let (w, y, z) = match *it {
Item::Box { w } => (w, 0.0, 0.0),
Item::Glue { w, st, sh } => (w, st, sh),
Item::Penalty { .. } => (0.0, 0.0, 0.0),
};
sw[i + 1] = sw[i] + w;
sy[i + 1] = sy[i] + y;
sz[i + 1] = sz[i] + z;
}
let mut arena: Vec<Node> = vec![Node { pos: 0, line: 0, fitness: 1, tw: 0.0, ty: 0.0, tz: 0.0, demerits: 0.0, prev: None, hyphens: 0, flagged: false }];
let mut active: Vec<usize> = vec![0];
for b in 0..m {
let (is_break, pw, pp, flagged) = match items[b] {
Item::Penalty { w, p, flagged } if p < INF => (true, w, p, flagged),
Item::Glue { .. } if b > 0 && matches!(items[b - 1], Item::Box { .. }) => (true, 0.0, 0.0, false),
_ => (false, 0.0, 0.0, false),
};
if !is_break {
continue;
}
let forced = pp <= -INF;
let mut best: [Option<(f64, usize, f64)>; 4] = [None; 4]; // (demerits, active node, ratio)
let mut keep = Vec::with_capacity(active.len());
let mut last_removed: Option<(usize, f64)> = None;
for &a in &active {
let n = arena[a];
let l = sw[b] - n.tw + pw;
let target = width(n.line).max(1.0);
let (y, z) = (sy[b] - n.ty, sz[b] - n.tz);
let r = if l < target {
if y > 1e-9 { (target - l) / y } else { INF }
} else if l > target {
if z > 1e-9 { (target - l) / z } else { -INF }
} else {
0.0
};
let deactivate = r < -1.0 || forced;
if !deactivate {
keep.push(a);
} else {
last_removed = Some((a, r));
}
if r >= -1.0 && r <= tol || (forced && r >= -1.0) {
let bad = (100.0 * r.abs().powi(3)).min(INF);
let lp = 10.0 + bad;
let mut d = if pp >= 0.0 { lp * lp + pp * pp } else if pp > -INF { lp * lp - pp * pp } else { lp * lp };
if flagged && n.flagged {
d += 3000.0;
}
if flagged && sp.hyphen_limit > 0 && n.hyphens >= sp.hyphen_limit {
d += 1e7;
}
let fit = if r < -0.5 {
0
} else if r <= 0.5 {
1
} else if r <= 1.0 {
2
} else {
3
};
if (fit as i32 - n.fitness as i32).abs() > 1 {
d += 3000.0;
}
let total = n.demerits + d;
if best[fit as usize].is_none_or(|(bd, _, _)| total < bd) {
best[fit as usize] = Some((total, a, r));
}
}
}
// Emergency: nothing can reach this break and nothing remains active → accept an overfull line.
if emergency
&& keep.is_empty()
&& best.iter().all(Option::is_none)
&& let Some((a, _)) = last_removed
{
best[0] = Some((arena[a].demerits + 1e8, a, -1.0));
}
active = keep;
// Totals after the break: skip glue and penalties up to the next box.
let (mut tw, mut ty, mut tz) = (sw[b], sy[b], sz[b]);
let mut i = b;
while i < m {
match items[i] {
Item::Box { .. } => break,
Item::Glue { w, st, sh } => {
tw += w;
ty += st;
tz += sh;
}
Item::Penalty { p, .. } if p <= -INF && i > b => break,
Item::Penalty { .. } => {}
}
i += 1;
}
for (fit, c) in best.iter().enumerate() {
if let Some((d, a, _)) = *c {
let prev = arena[a];
arena.push(Node {
pos: b,
line: prev.line + 1,
fitness: fit as u8,
tw,
ty,
tz,
demerits: d,
prev: Some(a),
hyphens: if flagged { prev.hyphens + 1 } else { 0 },
flagged,
});
active.push(arena.len() - 1);
}
}
if active.is_empty() {
return None;
}
}
// The final forced break: best node at position m-1.
let end = active.iter().copied().filter(|&a| arena[a].pos == m - 1).min_by(|&a, &b| arena[a].demerits.total_cmp(&arena[b].demerits))?;
let mut chain = Vec::new();
let mut cur = Some(end);
while let Some(c) = cur {
if arena[c].prev.is_some() {
chain.push(arena[c].pos);
}
cur = arena[c].prev;
}
chain.reverse();
Some(breaks_from_positions(items, ig, glyphs, &chain))
}
fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: &[usize]) -> Vec<Break> {
let n = glyphs.len();
let mut out = Vec::with_capacity(chain.len());
let mut start = 0;
for &b in chain {
let (end, hyphen, forced) = match items[b] {
Item::Penalty { p, flagged, w } => {
let e = ig[b].min(n);
// A flagged penalty with width = an inserted hyphen (not an explicit '-').
let explicit = e > 0 && matches!(glyphs[e - 1].ch, '-' | '\u{2010}');
(e, flagged && w > 0.0 && !explicit || (flagged && e > 0 && glyphs[e - 1].ch == SOFT_HYPHEN), p <= -INF)
}
Item::Glue { .. } => (ig[b], false, false),
Item::Box { .. } => (ig[b], false, false),
};
// Trim trailing spaces from the visible range; skip leading spaces of the next line.
let mut vis_end = end;
while vis_end > start && glyphs[vis_end - 1].is_space() {
vis_end -= 1;
}
let mut next = end;
while next < n && glyphs[next].is_space() {
next += 1;
}
let forced_glyph = end > 0 && end <= n && is_forced(glyphs[end - 1].ch);
out.push(Break { start, end: vis_end, next, hyphen, forced: forced || forced_glyph });
start = next;
}
if out.is_empty() {
out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true });
}
// Lines produced past the paragraph end (e.g. a trailing forced break) are dropped.
out.retain(|b| b.start <= n);
out
}
/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs).
pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
let n = glyphs.len();
let mut out = Vec::new();
let mut start = 0;
let mut line = 0;
if n == 0 {
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
}
while start < n {
let w = width(line).max(1.0);
let mut x = 0.0;
let mut spaces_shrink = 0.0;
let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen)
let mut i = start;
let mut brk: Option<(usize, bool, bool)> = None;
while i < n {
let g = &glyphs[i];
if is_forced(g.ch) {
brk = Some((i + 1, false, true));
break;
}
if g.is_space() && !g.no_break {
if sp.justify {
spaces_shrink += g.adv * (sp.word_desired - sp.word_min).max(0.0) / sp.word_desired.max(1e-6);
}
last_ok = Some((i, false));
x += g.adv;
i += 1;
continue;
}
let hy = if hyph_after[i] { g.size * 0.33 } else { 0.0 };
if x + g.adv > w + spaces_shrink && i > start {
// Overflow: break at the last opportunity, else before this glyph.
brk = Some(match last_ok {
Some((p, h)) => (p, h, false),
None => (i, false, false),
});
break;
}
x += g.adv;
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break {
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') {
last_ok = Some((i + 1, false));
} else if hyph_after[i] && x + hy <= w + spaces_shrink {
last_ok = Some((i + 1, true));
}
}
i += 1;
}
let (end, hyphen, forced) = brk.unwrap_or((n, false, true));
let mut vis_end = end;
while vis_end > start && glyphs[vis_end - 1].is_space() {
vis_end -= 1;
}
let mut next = end;
while next < n && glyphs[next].is_space() {
next += 1;
}
let next = next.max(start + 1).min(n.max(start + 1));
out.push(Break { start, end: vis_end, next: next.min(n), hyphen, forced: forced || end == n });
if next >= n {
break;
}
start = next;
line += 1;
}
// A forced break as the very last glyph leaves an empty final line (InDesign shows it).
if let Some(last) = glyphs.last()
&& is_forced(last.ch)
{
out.push(Break { start: n, end: n, next: n, hyphen: false, forced: true });
}
out
}

View File

@ -0,0 +1,93 @@
//! Composition cache shared by the engine, the renderer and exporters.
//!
//! An entry is valid while the inputs it was composed from are the *same allocations*: the story
//! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every
//! wrapping item on the frames' spreads. Entries hold clones of those `Arc`s, so the pointers can't
//! be reused by other allocations while the entry lives.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode};
use crate::{ComposeOptions, ComposedStory};
type Key = (StoryId, Option<String>);
struct Entry {
sig: Vec<usize>,
_keep: (Arc<Story>, Arc<Styles>, Vec<Arc<Item>>),
out: Arc<ComposedStory>,
stamp: u64,
}
#[derive(Default)]
pub struct Cache {
map: Mutex<(HashMap<Key, Entry>, u64)>,
}
impl Cache {
pub fn new() -> Self {
Self::default()
}
/// The composed story, from cache or freshly composed.
pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
let (sig, keep_items) = signature(doc, story);
let key = (sid, page_name.map(str::to_string));
{
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
g.1 += 1;
let stamp = g.1;
if let Some(e) = g.0.get_mut(&key)
&& e.sig == sig
{
e.stamp = stamp;
return e.out.clone();
}
}
let out = Arc::new(crate::compose_story(doc, sid, &ComposeOptions { page_name: page_name.map(str::to_string) }));
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let stamp = g.1;
if g.0.len() > 4096 {
g.0.retain(|_, e| stamp - e.stamp < 64);
}
g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
out
}
pub fn clear(&self) {
self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear();
}
}
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
let mut sig = vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len()];
let mut keep = Vec::new();
let mut spreads = Vec::new();
for f in &story.frames {
let Some(loc) = doc.find(*f) else {
sig.push(0);
continue;
};
if let Some(sp) = doc.spread(loc.spread) {
let top = &sp.items[loc.top()];
sig.push(Arc::as_ptr(top) as usize);
keep.push(top.clone());
// Page position matters for page-number markers.
sig.push(loc.top());
if !spreads.contains(&loc.spread) {
spreads.push(loc.spread);
for it in &sp.items {
if it.wrap.mode != WrapMode::None {
sig.push(Arc::as_ptr(it) as usize);
keep.push(it.clone());
}
}
sig.push(usize::MAX);
}
}
}
(sig, keep)
}

View File

@ -0,0 +1,120 @@
//! Dictionary-free English hyphenation (ours): breaks between syllables at vowel–consonant
//! boundaries (V-CV "ty-po", VC-CV "hap-pen"), respecting onset clusters ("gra-phy").
//!
//! A Liang pattern engine with loadable pattern sets is planned (see plan/adr); patterns are only
//! bundled once their licence is confirmed to fit the asset policy.
/// Hyphenation limits from the paragraph's Hyphenation settings.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub min_word: usize,
pub after_first: usize,
pub before_last: usize,
pub capitalized: bool,
}
impl Default for Limits {
fn default() -> Self {
Limits { min_word: 5, after_first: 2, before_last: 2, capitalized: true }
}
}
fn is_vowel(c: char) -> bool {
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
}
fn onset_pair(a: char, b: char) -> bool {
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
matches!(
(a, b),
('c' | 's' | 't' | 'p' | 'w' | 'g', 'h')
| ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r')
| ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l')
| ('q', 'u')
| ('s', 't' | 'p' | 'c' | 'k')
)
}
/// Common English suffixes that form their own syllable (break before them).
const SUFFIXES: &[&str] = &["tion", "sion", "ment", "ness", "less", "ful", "able", "ible", "ing", "ly"];
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char).
pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {
let c: Vec<char> = word.chars().collect();
let n = c.len();
let min_before = lim.after_first.max(1);
let min_after = lim.before_last.max(1);
if n < lim.min_word.max(2) || n < min_before + min_after || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
return vec![];
}
if c.iter().all(|c| c.is_uppercase()) || (!lim.capitalized && c[0].is_uppercase()) {
return vec![];
}
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
let lower: String = word.to_lowercase();
let mut out = vec![];
for i in min_before..=n - min_after {
if !c[i].is_alphabetic() || !c[i - 1].is_alphabetic() {
continue;
}
let suffix_at = SUFFIXES.iter().any(|s| {
let sl = s.chars().count();
n - i == sl && lower.ends_with(s) && v[..i].iter().any(|x| *x)
});
let ok = suffix_at
|| if v[i - 1] && !v[i] {
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
} else if !v[i - 1] && !v[i] {
i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2])
} else {
false
};
if ok && !out.contains(&i) {
out.push(i);
}
}
out.sort();
out
}
/// `word` with `-` at every hyphenation point (tests and diagnostics).
pub fn hyphenate_word(word: &str, lim: &Limits) -> String {
let pts = hyphen_points(word, lim);
let mut s = String::with_capacity(word.len() + pts.len());
for (i, ch) in word.chars().enumerate() {
if pts.contains(&i) {
s.push('-');
}
s.push(ch);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn h(w: &str) -> String {
hyphenate_word(w, &Limits::default())
}
#[test]
fn common_words() {
assert_eq!(h("happen"), "hap-pen");
assert_eq!(h("typography"), "ty-po-gra-phy");
assert!(h("composition").contains("com-"));
assert_eq!(h("cat"), "cat");
assert_eq!(h("NASA"), "NASA");
}
#[test]
fn limits_are_respected() {
let lim = Limits { min_word: 5, after_first: 3, before_last: 3, capitalized: false };
for w in ["typography", "hyphenation", "justification", "Paragraph", "beautiful"] {
for p in hyphen_points(w, &lim) {
assert!(p >= 3 && p <= w.chars().count() - 3, "{w} {p}");
}
}
assert!(hyphen_points("Paragraph", &lim).is_empty());
}
}

913
crates/compose/src/lib.rs Normal file
View File

@ -0,0 +1,913 @@
//! 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;
pub mod shape;
use std::ops::Range;
use std::sync::Arc;
use designcraft_doc::{
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
VerticalJustification, WrapMode, story,
};
use designcraft_fonts::{FontDb, FontFace};
use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::shape::{Glyph, StyleTable, SubstCtx};
/// 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,
pub skew: f64,
pub size: f64,
}
/// 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: Arc<FontFace>,
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,
}
#[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>,
/// 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>,
}
/// 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, 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)), 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), false)
}
_ => (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));
out.push(FrameSpec { id: fid, area: item.text_area(), opts: tf.options.clone(), exclusions, page_name, 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();
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 mut list_counter: u32 = 0;
'paras: for (pi, prange) in para_ranges.iter().enumerate() {
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
// Bullets & numbering: generated prefix (shaped as its own glyphs, mapped to the paragraph start).
let sub = SubstCtx {
page_name: opts.page_name.clone().or_else(|| frames.get(cur.fi.min(frames.len().saturating_sub(1))).and_then(|f| f.page_name.clone())),
section_marker: None,
};
let mut table = StyleTable { styles: &mut styles_tab };
let mut sp = shape::shape_para(db, &doc.styles, story, pi, prange.clone(), &base_chars, pp.auto_leading, &sub, &mut table);
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,
}
let glyphs = sp.glyphs;
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 {
justify: pp.align.is_justified(),
word_min: pp.word_space_min,
word_desired: pp.word_space_desired,
word_max: pp.word_space_max,
hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight,
hyphen_limit: pp.hyph_limit,
ragged_stretch: base_size * 2.0,
};
// Paragraph start options.
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;
}
// Rule above / shading track the paragraph's first line.
let mut g0 = 0usize;
let mut line_no = 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 {
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 fits = baseline + desc <= col.y1 + 0.01 || (cur.last_baseline.is_none() && baseline <= col.y1 && false);
if !fits {
// Next column / frame; re-break the rest of the paragraph there.
g0 = s;
cur.next_column(&cols);
moved = true;
let _ = k;
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, 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,
});
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);
match brk {
Some(story::COLUMN_BREAK) => cur.next_column(&cols),
Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => cur.next_frame(),
_ => {}
}
}
}
if !moved {
break;
}
}
// 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;
}
// 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;
}
vertical_justify(ft, f);
}
// 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
}
fn ft_prev_end(overset: &Option<usize>, len: usize) -> usize {
overset.unwrap_or(len)
}
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).
#[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,
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;
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();
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 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 has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB);
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;
let mut per_space = 0.0;
let mut offset = 0.0;
let mut ratio = 1.0;
if justify_this && !spaces.is_empty() {
per_space = extra / spaces.len() as f64;
let space_w: f64 = spaces.iter().map(|&i| line[i].adv).sum::<f64>() / spaces.len() as f64;
ratio = if space_w > 0.0 { (space_w + per_space) / space_w * pp.word_space_desired } else { 1.0 };
} else if justify_this && spaces.is_empty() && line.len() > 1 && !last {
// Single word: Single Word Justification.
match pp.single_word_justify {
Align::FullyJustified => {
let gaps = (line.len() - 1) as f64;
let per = extra / gaps;
let mut x = x0;
let mut out = Vec::with_capacity(line.len());
for (i, g) in line.iter().enumerate() {
out.push(place(g, x));
x += g.adv + if i + 1 < line.len() { per } else { 0.0 };
}
return (out, x, 1.0);
}
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;
}
}
let mut out = Vec::with_capacity(line.len());
let mut x = x0 + offset;
for (i, g) in line.iter().enumerate() {
out.push(place(g, x));
x += g.adv;
if per_space != 0.0 && spaces.binary_search(&i).is_ok() {
x += per_space;
if let Some(p) = out.last_mut() {
p.adv += per_space;
}
}
}
(out, x, ratio)
}
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 == shape::SOFT_HYPHEN);
PlacedGlyph { face: g.face.clone(), 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;
}
/// Mark glyphs after which a hyphen may be inserted.
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,
};
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].no_break {
j += 1;
}
let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len);
if 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 = hyphen::hyphen_points(word, &lim);
for p in pts {
// 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;

334
crates/compose/src/shape.rs Normal file
View File

@ -0,0 +1,334 @@
//! Paragraph → styled, positioned glyphs (before line breaking).
use std::sync::Arc;
use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story};
use designcraft_fonts::{Feature, FontDb, FontFace, ShapedGlyph, feature};
use crate::RunStyle;
/// One glyph with every character attribute applied, in points.
#[derive(Clone, Debug)]
pub struct Glyph {
pub face: Arc<FontFace>,
pub gid: u32,
/// Story byte offset of the cluster and its byte length (0 for generated glyphs).
pub byte: usize,
pub len: usize,
/// First source character of the cluster (special characters keep their code).
pub ch: char,
/// Advance in points (tracking and horizontal scale applied).
pub adv: f64,
pub dx: f64,
pub dy: f64,
/// Font units → points (incl. horizontal / vertical scale).
pub sx: f64,
pub sy: f64,
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
pub cap: f64,
pub xh: f64,
pub size: f64,
/// Index into the composed story's [`RunStyle`] table.
pub style: u32,
/// Unbreakable (No Break).
pub no_break: bool,
}
impl Glyph {
pub fn is_space(&self) -> bool {
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}' | '\u{3000}')
}
pub fn is_letter(&self) -> bool {
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
}
}
pub const SOFT_HYPHEN: char = '\u{AD}';
/// Shaped paragraph.
pub struct ShapedPara {
pub glyphs: Vec<Glyph>,
/// Byte range of the paragraph text (without its `\n`).
pub range: std::ops::Range<usize>,
}
/// Context for substitutions (page number markers).
#[derive(Clone, Debug, Default)]
pub struct SubstCtx {
pub page_name: Option<String>,
pub section_marker: Option<String>,
}
pub(crate) struct StyleTable<'a> {
pub styles: &'a mut Vec<RunStyle>,
}
impl StyleTable<'_> {
fn intern(&mut self, p: &CharProps) -> u32 {
let rs = RunStyle {
fill: p.fill.clone(),
fill_tint: p.fill_tint,
stroke: p.stroke.clone(),
stroke_tint: p.stroke_tint,
stroke_weight: p.stroke_weight,
underline: p.underline,
strikethrough: p.strikethrough,
skew: p.skew,
size: p.size,
};
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
return i as u32;
}
self.styles.push(rs);
(self.styles.len() - 1) as u32
}
}
/// Resolve and shape one paragraph.
pub(crate) fn shape_para(
db: &FontDb,
styles: &Styles,
story: &Story,
pi: usize,
range: std::ops::Range<usize>,
para_chars: &CharProps,
auto_leading: f64,
sub: &SubstCtx,
table: &mut StyleTable<'_>,
) -> ShapedPara {
let _ = pi;
let mut glyphs = Vec::with_capacity(range.len());
for (rr, fmt) in story.runs() {
let a = rr.start.max(range.start);
let b = rr.end.min(range.end);
if a >= b {
continue;
}
let props = styles.resolve_char(para_chars, fmt);
let style = table.intern(&props);
shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs);
}
ShapedPara { glyphs, range }
}
fn features_for(p: &CharProps) -> Vec<Feature> {
let mut v = Vec::new();
if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) {
v.extend(feature("-kern"));
}
if !p.ligatures || p.tracking.abs() > 1e-9 {
v.extend(feature("-liga"));
v.extend(feature("-clig"));
}
if p.capitalization == Capitalization::AllCaps {
v.extend(feature("case"));
}
if matches!(p.capitalization, Capitalization::SmallCaps | Capitalization::OpenTypeAllSmallCaps) {
v.extend(feature("smcp"));
}
if p.capitalization == Capitalization::OpenTypeAllSmallCaps {
v.extend(feature("c2sc"));
}
match p.position {
Position::OtSuperscript => v.extend(feature("sups")),
Position::OtSubscript => v.extend(feature("subs")),
Position::OtNumerator => v.extend(feature("numr")),
Position::OtDenominator => v.extend(feature("dnom")),
_ => {}
}
for f in &p.otf_features {
v.extend(feature(f));
}
v
}
fn is_mark(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
}
#[allow(clippy::too_many_arguments)]
fn shape_run(db: &FontDb, text: &str, range: std::ops::Range<usize>, p: &CharProps, auto_leading: f64, style: u32, sub: &SubstCtx, out: &mut Vec<Glyph>) {
let primary = db.face(&p.font_family, &p.font_style);
// Split into segments: font coverage changes and special characters (markers, tabs, breaks).
let mut seg_start = range.start;
let mut seg_face = primary.clone();
let flush = |a: usize, b: usize, face: &Arc<FontFace>, out: &mut Vec<Glyph>| {
if a < b {
shape_segment(db, text, a..b, None, p, face, auto_leading, style, out);
}
};
for (i, c) in text[range.clone()].char_indices() {
let i = range.start + i;
let special = matches!(
c,
'\t' | story::FORCED_LINE_BREAK
| story::PAGE_NUMBER
| story::NEXT_PAGE_NUMBER
| story::PREV_PAGE_NUMBER
| story::SECTION_MARKER
| story::COLUMN_BREAK
| story::FRAME_BREAK
| story::PAGE_BREAK
| story::INDENT_HERE
| story::RIGHT_INDENT_TAB
);
if special {
flush(seg_start, i, &seg_face, out);
seg_start = i + c.len_utf8();
match c {
story::PAGE_NUMBER | story::NEXT_PAGE_NUMBER | story::PREV_PAGE_NUMBER | story::SECTION_MARKER => {
let s = if c == story::SECTION_MARKER {
sub.section_marker.clone().unwrap_or_else(|| "Section".into())
} else {
sub.page_name.clone().unwrap_or_else(|| "#".into())
};
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out);
}
_ => {
// Zero-width control glyph carrying metrics (tabs get their width at line layout).
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
if c == '\t' || c == story::RIGHT_INDENT_TAB {
g.adv = 0.0;
}
out.push(g);
}
}
continue;
}
let covered = c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN || primary.covers(c);
let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone()) };
if face.id() != seg_face.id() {
flush(seg_start, i, &seg_face, out);
seg_start = i;
seg_face = face;
}
}
flush(seg_start, range.end, &seg_face, out);
}
fn metrics(face: &FontFace, p: &CharProps, auto_leading: f64) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
let (size, shift) = effective_size(p);
let k = size / face.upem;
let vs = p.v_scale;
let leading = match p.leading {
Leading::Auto => p.size * auto_leading,
Leading::Points(v) => v,
};
(size, k, face.ascent * k * vs, face.descent * k * vs, leading, face.cap_height * k * vs, face.x_height * k * vs, shift)
}
/// Super/subscript use 58.3% size and ±33.3% / −10% shift (InDesign's defaults).
fn effective_size(p: &CharProps) -> (f64, f64) {
match p.position {
Position::Superscript => (p.size * 0.583, p.baseline_shift + p.size * 0.333),
Position::Subscript => (p.size * 0.583, p.baseline_shift - p.size * 0.333 * 0.3),
_ => (p.size, p.baseline_shift),
}
}
fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: f64, style: u32, byte: usize, ch: char) -> Glyph {
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
Glyph {
face: face.clone(),
gid: face.glyph_for(' '),
byte,
len: ch.len_utf8(),
ch,
adv: 0.0,
dx: 0.0,
dy: 0.0,
sx: k * p.h_scale,
sy: k * p.v_scale,
shift,
ascent,
descent,
leading,
cap,
xh,
size,
style,
no_break: p.no_break,
}
}
#[allow(clippy::too_many_arguments)]
fn shape_segment(
db: &FontDb,
text: &str,
range: std::ops::Range<usize>,
replacement: Option<&str>,
p: &CharProps,
face: &Arc<FontFace>,
auto_leading: f64,
style: u32,
out: &mut Vec<Glyph>,
) {
let _ = db;
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
let hs = p.h_scale;
let tracking = p.tracking / 1000.0 * p.size;
let manual = if let Kerning::Manual(v) = p.kerning { v / 1000.0 * p.size } else { 0.0 };
let src = replacement.unwrap_or(&text[range.clone()]);
let caps = p.capitalization == Capitalization::AllCaps;
let feats = features_for(p);
let shaped: Vec<ShapedGlyph> = designcraft_fonts::shape(face, src, &feats, |c| if caps { c.to_uppercase().next().unwrap_or(c) } else { c });
let n = shaped.len();
for (gi, sg) in shaped.iter().enumerate() {
let (byte, len, ch) = if replacement.is_some() {
(range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' '))
} else {
let cl = range.start + sg.cluster;
let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end);
(cl, end.saturating_sub(cl), text[cl..].chars().next().unwrap_or(' '))
};
let last_in_cluster = gi + 1 == n || shaped[gi + 1].cluster != sg.cluster;
let mut adv = sg.x_advance as f64 * k * hs;
if ch == SOFT_HYPHEN {
adv = 0.0;
} else if last_in_cluster {
adv += tracking + manual;
}
// Only the first glyph of a cluster owns the bytes (so ranges partition the text).
let first_in_cluster = gi == 0 || shaped[gi - 1].cluster != sg.cluster;
out.push(Glyph {
face: face.clone(),
gid: sg.gid,
byte,
len: if first_in_cluster || replacement.is_some() { len } else { 0 },
ch,
adv,
dx: sg.x_offset as f64 * k * hs,
dy: -(sg.y_offset as f64) * k * p.v_scale,
sx: k * hs,
sy: k * p.v_scale,
shift,
ascent,
descent,
leading,
cap,
xh,
size,
style,
no_break: p.no_break,
});
}
}
/// A hyphen glyph in the face/size of `g`, placed after it (zero source length).
pub(crate) fn hyphen_after(g: &Glyph) -> Glyph {
let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']);
let mut h = g.clone();
h.gid = gid;
h.adv = g.face.advance(gid) * g.sx;
h.dx = 0.0;
h.dy = 0.0;
h.byte = g.byte + g.len;
h.len = 0;
h.ch = '-';
h
}

172
crates/compose/src/tests.rs Normal file
View File

@ -0,0 +1,172 @@
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, SpreadRef};
use designcraft_geom::Rect;
use super::*;
const LOREM: &str = "Typography is the art and technique of arranging type to make written language legible, readable and appealing when displayed. \
The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing, and adjusting the space between pairs of letters.";
fn doc_with(text: &str, rect: Rect, para: ParaAttrs) -> (Document, StoryId, ItemId) {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, ParaFormat { para, ..Default::default() }).unwrap();
(d, sid, fid)
}
fn all_lines(cs: &ComposedStory) -> Vec<&Line> {
cs.frames.iter().flat_map(|f| f.lines.iter()).collect()
}
#[test]
fn composes_simple_paragraph() {
let (d, sid, _) = doc_with(LOREM, Rect::new(36.0, 36.0, 300.0, 700.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let lines = all_lines(&cs);
assert!(lines.len() >= 5, "{}", lines.len());
// Baselines increase by the auto leading (12 × 120% = 14.4).
for w in lines.windows(2) {
assert!((w[1].baseline - w[0].baseline - 14.4).abs() < 1e-6);
}
// Lines fit their measure.
for l in &lines {
assert!(l.end_x <= l.x1 + 0.5, "line overflows: {} > {}", l.end_x, l.x1);
}
}
#[test]
fn line_ranges_partition_the_story() {
let text = format!("{LOREM}\n\nSecond paragraph here.\n{LOREM}");
let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 200.0, 2000.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let mut pos = 0;
for l in &lines {
// Each line starts where the previous ended (or after a paragraph separator).
assert!(l.range.start == pos || l.range.start == pos + 1, "gap at {pos}: {:?}", l.range);
pos = l.range.end;
}
assert_eq!(pos, text.len());
assert_eq!(lines.iter().filter(|l| l.first_in_para).count(), 4);
}
#[test]
fn justified_lines_fill_the_measure() {
let (d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 220.0, 2000.0), ParaAttrs { align: Some(Align::LeftJustified), ..Default::default() });
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
for l in &lines[..lines.len() - 1] {
assert!((l.end_x - l.x1).abs() < 0.6, "justified line ends at {} not {}", l.end_x, l.x1);
}
let last = lines.last().unwrap();
assert!(last.end_x < last.x1 - 1.0, "last line is ragged");
}
#[test]
fn small_frame_oversets() {
let (d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 40.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.is_overset());
let shown = cs.frames[0].range.clone();
assert_eq!(cs.overset_at.unwrap(), shown.end.max(cs.overset_at.unwrap()).min(cs.overset_at.unwrap()));
assert!(cs.frames[0].lines.len() <= 3);
}
#[test]
fn text_flows_through_threaded_frames() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let long = [LOREM; 4].join("\n");
let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 120.0), lid, &long, ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(250.0, 0.0, 450.0, 2000.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, b).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
assert!(!cs.frames[0].lines.is_empty() && !cs.frames[1].lines.is_empty());
assert_eq!(cs.frames[0].range.end, cs.frames[1].range.start.min(cs.frames[0].range.end).max(cs.frames[0].range.end));
// The second frame's lines start at its top.
assert!(cs.frames[1].lines[0].baseline < 20.0);
}
#[test]
fn columns_fill_left_to_right() {
let (mut d, sid, fid) = doc_with(&[LOREM; 3].join(" "), Rect::new(0.0, 0.0, 400.0, 150.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!(lines.iter().any(|l| l.column == 1));
let c1 = lines.iter().find(|l| l.column == 1).unwrap();
assert!(c1.x0 > 200.0);
}
#[test]
fn centered_text_is_centered() {
let (d, sid, _) = doc_with("Hello", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs { align: Some(Align::Center), ..Default::default() });
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let left = l.glyphs[0].x - l.x0;
let right = l.x1 - l.end_x;
assert!((left - right).abs() < 0.5, "{left} vs {right}");
}
#[test]
fn caret_and_hit_roundtrip() {
let (d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 1000.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
for pos in [0, 5, 40, 100, LOREM.len()] {
let (fi, x, b, _, _) = caret(&cs, pos).unwrap();
let back = hit(&cs, fi, Point::new(x + 0.1, b - 2.0)).unwrap();
assert!((back as i64 - pos as i64).abs() <= 1, "{pos} -> {back}");
}
}
#[test]
fn empty_story_has_one_line() {
let (d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert_eq!(cs.line_count(), 1);
assert!(caret(&cs, 0).is_some());
}
#[test]
fn wrap_pushes_text_aside() {
let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0)));
it.wrap.mode = WrapMode::BoundingBox;
it.wrap.offsets = [0.0, 0.0, 6.0, 6.0];
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = &cs.frames[0].lines[0];
assert!(first.x0 >= 126.0 - 1e-6, "{}", first.x0);
let below = cs.frames[0].lines.iter().find(|l| l.baseline - l.ascent > 66.0).unwrap();
assert!(below.x0 < 1.0);
}
#[test]
fn paragraph_composer_is_no_worse_than_greedy() {
// Sum of squared slack over lines (excluding last) should not exceed the greedy result.
let measure = 180.0;
let mk = |c: designcraft_doc::Composer| {
let (d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, measure, 4000.0), ParaAttrs { composer: Some(c), hyphenate: Some(false), ..Default::default() });
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let n = lines.len();
lines[..n - 1].iter().map(|l| (l.x1 - l.end_x).powi(2)).sum::<f64>()
};
let kp = mk(designcraft_doc::Composer::Paragraph);
let gr = mk(designcraft_doc::Composer::SingleLine);
assert!(kp <= gr * 1.05 + 1.0, "kp {kp} greedy {gr}");
}
#[test]
fn tabs_align_to_stops() {
let mut pa = ParaAttrs::default();
pa.tabs = Some(vec![designcraft_doc::TabStop { position: 100.0, align: TabAlign::Right, leader: String::new(), align_on: String::new() }]);
let (d, sid, _) = doc_with("Name\t42", Rect::new(0.0, 0.0, 300.0, 100.0), pa);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
assert!((l.end_x - 100.0).abs() < 0.5, "{}", l.end_x);
}

View File

@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 3e642ee9cd6b3dfe88454002147ccd4c5d072f080b54f02b3e28f85dccbdf9cb # shrinks to ops = [Ins(0, ""), Ins(0, "éé"), Ins(21, "é"), Fmt(21, 1, 0)]

498
crates/doc/src/build.rs Normal file
View File

@ -0,0 +1,498 @@
//! Creating documents (File → New → Document), presets, and page/spread structure edits
//! (insert/delete/move pages, repagination into spreads, parents).
use std::collections::BTreeMap;
use std::sync::Arc;
use designcraft_geom::{Affine, Unit, units};
use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, LayerId, PageId, SpreadId};
use crate::item::Item;
use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread};
use crate::styles::Styles;
use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, Result};
/// A New Document preset.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Preset {
pub name: &'static str,
pub intent: Intent,
pub width: f64,
pub height: f64,
pub units: Unit,
}
const MM: f64 = units::PT_PER_MM;
pub const PRESETS: &[Preset] = &[
Preset { name: "Letter", intent: Intent::Print, width: 612.0, height: 792.0, units: Unit::Picas },
Preset { name: "Legal", intent: Intent::Print, width: 612.0, height: 1008.0, units: Unit::Picas },
Preset { name: "Tabloid", intent: Intent::Print, width: 792.0, height: 1224.0, units: Unit::Picas },
Preset { name: "Letter - Half", intent: Intent::Print, width: 396.0, height: 612.0, units: Unit::Picas },
Preset { name: "A3", intent: Intent::Print, width: 297.0 * MM, height: 420.0 * MM, units: Unit::Millimeters },
Preset { name: "A4", intent: Intent::Print, width: 210.0 * MM, height: 297.0 * MM, units: Unit::Millimeters },
Preset { name: "A5", intent: Intent::Print, width: 148.0 * MM, height: 210.0 * MM, units: Unit::Millimeters },
Preset { name: "B5", intent: Intent::Print, width: 176.0 * MM, height: 250.0 * MM, units: Unit::Millimeters },
Preset { name: "Business Card", intent: Intent::Print, width: 252.0, height: 144.0, units: Unit::Picas },
Preset { name: "Postcard", intent: Intent::Print, width: 432.0, height: 288.0, units: Unit::Picas },
Preset { name: "Web 1920 × 1080", intent: Intent::Web, width: 1920.0, height: 1080.0, units: Unit::Pixels },
Preset { name: "Web 1366 × 768", intent: Intent::Web, width: 1366.0, height: 768.0, units: Unit::Pixels },
Preset { name: "Web 1024 × 768", intent: Intent::Web, width: 1024.0, height: 768.0, units: Unit::Pixels },
Preset { name: "Mobile 1080 × 1920", intent: Intent::Mobile, width: 1080.0, height: 1920.0, units: Unit::Pixels },
Preset { name: "Mobile 390 × 844", intent: Intent::Mobile, width: 390.0, height: 844.0, units: Unit::Pixels },
Preset { name: "Tablet 1024 × 1366", intent: Intent::Mobile, width: 1024.0, height: 1366.0, units: Unit::Pixels },
];
/// File → New → Document parameters.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NewDocument {
pub title: String,
pub intent: Intent,
pub width: f64,
pub height: f64,
pub pages: usize,
pub start_page: u32,
pub facing_pages: bool,
pub primary_text_frame: bool,
pub columns: u32,
pub gutter: f64,
pub margins: Margins,
pub bleed: [f64; 4],
pub slug: [f64; 4],
pub units: Unit,
}
impl Default for NewDocument {
fn default() -> Self {
NewDocument {
title: "Untitled-1".into(),
intent: Intent::Print,
width: 612.0,
height: 792.0,
pages: 1,
start_page: 1,
facing_pages: true,
primary_text_frame: false,
columns: 1,
gutter: 12.0,
margins: Margins::uniform(36.0),
bleed: [0.0; 4],
slug: [0.0; 4],
units: Unit::Picas,
}
}
}
impl NewDocument {
pub fn from_preset(name: &str) -> Option<Self> {
let p = PRESETS.iter().find(|p| p.name.eq_ignore_ascii_case(name))?;
let web = p.intent != Intent::Print;
Some(NewDocument {
intent: p.intent,
width: p.width,
height: p.height,
units: p.units,
facing_pages: !web,
margins: if p.units == Unit::Millimeters { Margins::uniform(12.7 * MM) } else { Margins::uniform(36.0) },
..Default::default()
})
}
}
impl Document {
/// Build a new document.
pub fn new(nd: &NewDocument) -> Document {
let settings = DocSettings {
intent: nd.intent,
page_width: nd.width,
page_height: nd.height,
facing_pages: nd.facing_pages,
primary_text_frame: nd.primary_text_frame,
bleed: nd.bleed,
slug: nd.slug,
horizontal_units: nd.units,
vertical_units: nd.units,
..Default::default()
};
let mut d = Document {
title: nd.title.clone(),
settings,
spreads: vec![],
parents: vec![],
layers: vec![],
stories: BTreeMap::new(),
styles: Arc::new(Styles::default()),
swatches: designcraft_color::default_swatches(),
sections: vec![Section {
start: 0,
start_number: Some(nd.start_page.max(1)),
style: Default::default(),
prefix: String::new(),
marker: String::new(),
include_prefix: false,
}],
assets: BTreeMap::new(),
next_id: 0,
};
let lid = LayerId(d.alloc());
d.layers.push(Layer {
id: lid,
name: "Layer 1".into(),
color: LAYER_COLORS[0].1,
visible: true,
locked: false,
printable: true,
show_guides: true,
suppress_wrap_when_hidden: false,
});
// A-Parent.
let parent_id = d.add_parent("A", "Parent", nd.columns, nd.gutter, nd.margins);
// Document pages.
let pages: Vec<Page> = (0..nd.pages.max(1)).map(|_| d.make_page(Some(parent_id), nd.columns, nd.gutter, nd.margins)).collect();
d.spreads = vec![Arc::new(Spread { id: SpreadId(d.alloc()), pages, items: vec![], parent: None, allow_shuffle: true })];
d.repaginate();
d
}
fn make_page(&mut self, parent: Option<SpreadId>, columns: u32, gutter: f64, margins: Margins) -> Page {
Page {
id: PageId(self.alloc()),
width: self.settings.page_width,
height: self.settings.page_height,
x: 0.0,
margins,
columns: Columns { count: columns.max(1), gutter, positions: None },
parent,
side: PageSide::Single,
overridden: vec![],
guides: vec![],
show_parent_items: true,
}
}
/// Add a parent spread (`prefix`-`name`) and return its id.
pub fn add_parent(&mut self, prefix: &str, name: &str, columns: u32, gutter: f64, margins: Margins) -> SpreadId {
let n = if self.settings.facing_pages { 2 } else { 1 };
let mut pages: Vec<Page> = (0..n).map(|_| self.make_page(None, columns, gutter, margins)).collect();
if n == 2 {
pages[0].side = PageSide::Left;
pages[1].side = PageSide::Right;
}
let id = SpreadId(self.alloc());
let mut sp = Spread { id, pages, items: vec![], parent: Some(ParentInfo { prefix: prefix.into(), name: name.into(), based_on: None }), allow_shuffle: true };
sp.relayout();
self.parents.push(Arc::new(sp));
id
}
pub fn parent_index(&self, id: SpreadId) -> Option<usize> {
self.parents.iter().position(|p| p.id == id)
}
/// The next free parent prefix (A, B, C…).
pub fn next_parent_prefix(&self) -> String {
for c in 'A'..='Z' {
let s = c.to_string();
if !self.parents.iter().any(|p| p.parent.as_ref().is_some_and(|i| i.prefix == s)) {
return s;
}
}
format!("P{}", self.parents.len() + 1)
}
/// Redistribute document pages into spreads (facing: page 1 alone on the right, then pairs).
/// Items move with the page their centre is on. Spreads with `allow_shuffle == false` are kept.
pub fn repaginate(&mut self) {
// Collect pages with their items (items in page-relative coordinates).
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new();
for sp in self.spreads.drain(..) {
if !sp.allow_shuffle && sp.pages.len() > 1 {
kept.push((pages.len(), sp));
continue;
}
let mut sp = Arc::unwrap_or_clone(sp);
let mut per: Vec<Vec<Arc<Item>>> = vec![Vec::new(); sp.pages.len()];
for it in std::mem::take(&mut sp.items) {
let cx = it.bounds().center().x;
let pi = sp.page_at_x(cx).unwrap_or(0);
let dx = sp.pages[pi].x;
let mut it = Arc::unwrap_or_clone(it);
it.xf = Affine::translate((-dx, 0.0)) * it.xf;
per[pi].push(Arc::new(it));
}
for (p, items) in sp.pages.into_iter().zip(per) {
pages.push((p, items));
}
}
let facing = self.settings.facing_pages;
let mut spreads: Vec<Spread> = Vec::new();
let mut cur: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
let total = pages.len();
let flush = |cur: &mut Vec<(Page, Vec<Arc<Item>>)>, spreads: &mut Vec<Spread>, id: SpreadId| {
if cur.is_empty() {
return;
}
let mut sp = Spread { id, pages: vec![], items: vec![], parent: None, allow_shuffle: true };
let mut x = 0.0;
let n = cur.len();
for (i, (mut p, items)) in cur.drain(..).enumerate() {
p.side = if !facing {
PageSide::Single
} else if n == 1 {
// Lone page: right if odd page number position, decided by caller via side preset.
p.side
} else if i == 0 {
PageSide::Left
} else {
PageSide::Right
};
p.x = x;
for it in items {
let mut it = Arc::unwrap_or_clone(it);
it.xf = Affine::translate((x, 0.0)) * it.xf;
sp.items.push(Arc::new(it));
}
x += p.width;
sp.pages.push(p);
}
spreads.push(sp);
};
for (i, (mut p, items)) in pages.into_iter().enumerate() {
if facing {
// Page i (0-based) is a right page when i is even (page 1, 3, 5 …).
p.side = if i % 2 == 0 { PageSide::Right } else { PageSide::Left };
if p.side == PageSide::Left && !cur.is_empty() {
let id = SpreadId(self.alloc());
flush(&mut cur, &mut spreads, id);
}
cur.push((p, items));
if cur.len() == 2 || i + 1 == total {
let id = SpreadId(self.alloc());
flush(&mut cur, &mut spreads, id);
}
} else {
cur.push((p, items));
let id = SpreadId(self.alloc());
flush(&mut cur, &mut spreads, id);
}
}
let mut out: Vec<Arc<Spread>> = spreads.into_iter().map(Arc::new).collect();
// Re-insert kept (non-shuffling) spreads at roughly their old page positions.
for (page_pos, sp) in kept.into_iter().rev() {
let mut n = 0;
let mut at = out.len();
for (i, s) in out.iter().enumerate() {
if n >= page_pos {
at = i;
break;
}
n += s.pages.len();
}
out.insert(at, sp);
}
self.spreads = out;
}
/// Insert `count` pages after absolute page `after` (None = at the start), using `parent`.
pub fn insert_pages(&mut self, after: Option<usize>, count: usize, parent: Option<SpreadId>) -> Result<Vec<PageId>> {
if count == 0 {
return Ok(vec![]);
}
let n = self.page_count();
let at = after.map_or(0, |a| (a + 1).min(n));
let template = self.page(at.saturating_sub(1).min(n.saturating_sub(1))).cloned();
let (cols, gutter, margins) =
template.as_ref().map(|t| (t.columns.count, t.columns.gutter, t.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
let mut ids = Vec::new();
let new_pages: Vec<Page> = (0..count)
.map(|_| {
let mut p = self.make_page(parent, cols, gutter, margins);
if let Some(t) = &template {
p.width = t.width;
p.height = t.height;
}
ids.push(p.id);
p
})
.collect();
// Splice into the spread holding page `at` (or append a new spread), then repaginate.
match self.page_loc(at) {
Some((si, pi)) => {
let sp = Arc::make_mut(&mut self.spreads[si]);
let x = sp.pages[pi].x;
let w: f64 = new_pages.iter().map(|p| p.width).sum();
// Shift items right of the insertion point.
for it in &mut sp.items {
if it.bounds().center().x >= x {
let it = Arc::make_mut(it);
it.xf = Affine::translate((w, 0.0)) * it.xf;
}
}
for (k, p) in new_pages.into_iter().enumerate() {
sp.pages.insert(pi + k, p);
}
sp.relayout();
}
None => {
let id = SpreadId(self.alloc());
let mut sp = Spread { id, pages: new_pages, items: vec![], parent: None, allow_shuffle: true };
sp.relayout();
self.spreads.push(Arc::new(sp));
}
}
self.repaginate();
Ok(ids)
}
/// Delete absolute pages (items whose centre is on a deleted page are deleted too).
pub fn delete_pages(&mut self, pages: &[usize]) -> Result<()> {
if pages.len() >= self.page_count() {
return Err(DocError::Invalid("a document needs at least one page".into()));
}
let mut doomed: Vec<(usize, usize)> = pages.iter().filter_map(|p| self.page_loc(*p)).collect();
doomed.sort();
doomed.dedup();
let mut remove_items: Vec<ItemId> = Vec::new();
for &(si, pi) in &doomed {
let sp = &self.spreads[si];
for it in &sp.items {
if sp.page_at_x(it.bounds().center().x) == Some(pi) {
remove_items.push(it.id);
}
}
}
for id in remove_items {
let _ = self.remove_item(id);
}
for &(si, pi) in doomed.iter().rev() {
let sp = Arc::make_mut(&mut self.spreads[si]);
let w = sp.pages[pi].width;
let x = sp.pages[pi].x;
sp.pages.remove(pi);
for it in &mut sp.items {
if it.bounds().center().x >= x + w {
let it = Arc::make_mut(it);
it.xf = Affine::translate((-w, 0.0)) * it.xf;
}
}
sp.relayout();
}
self.spreads.retain(|s| !s.pages.is_empty());
self.repaginate();
Ok(())
}
/// Move absolute page `from` so it ends up at index `to` (items travel with it).
pub fn move_page(&mut self, from: usize, to: usize) -> Result<()> {
let n = self.page_count();
if from >= n || to >= n {
return Err(DocError::NoPage(from.max(to)));
}
if from == to {
return Ok(());
}
// Flatten to single-page spreads, reorder, repaginate.
let facing = self.settings.facing_pages;
self.settings.facing_pages = false;
self.repaginate();
let sp = self.spreads.remove(from);
self.spreads.insert(to, sp);
self.settings.facing_pages = facing;
self.repaginate();
Ok(())
}
/// Apply a parent (None = [None]) to absolute pages.
pub fn apply_parent(&mut self, pages: &[usize], parent: Option<SpreadId>) -> Result<()> {
if let Some(p) = parent
&& self.parent_index(p).is_none()
{
return Err(DocError::Invalid(format!("no parent {p}")));
}
for &abs in pages {
let (si, pi) = self.page_loc(abs).ok_or(DocError::NoPage(abs))?;
Arc::make_mut(&mut self.spreads[si]).pages[pi].parent = parent;
}
Ok(())
}
/// Parent page (spread index, page index) to show behind document page `abs`.
pub fn parent_page_for(&self, abs: usize) -> Option<(usize, usize)> {
let page = self.page(abs)?;
let pi = self.parent_index(page.parent?)?;
let ps = &self.parents[pi];
let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len() - 1 };
Some((pi, idx))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_facing_document_layout() {
let d = Document::new(&NewDocument { pages: 4, ..Default::default() });
assert_eq!(d.page_count(), 4);
// [1] [2 3] [4]
assert_eq!(d.spreads.iter().map(|s| s.pages.len()).collect::<Vec<_>>(), vec![1, 2, 1]);
assert_eq!(d.spreads[0].pages[0].side, PageSide::Right);
assert_eq!(d.spreads[1].pages[0].side, PageSide::Left);
assert_eq!(d.spreads[1].pages[1].x, 612.0);
assert_eq!(d.parents.len(), 1);
assert_eq!(d.parents[0].pages.len(), 2);
d.check().unwrap();
}
#[test]
fn non_facing_one_page_per_spread() {
let d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() });
assert_eq!(d.spreads.len(), 3);
assert!(d.spreads.iter().all(|s| s.pages.len() == 1 && s.pages[0].side == PageSide::Single));
}
#[test]
fn insert_and_delete_pages_reshuffle() {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
d.insert_pages(Some(0), 3, d.parents.first().map(|p| p.id)).unwrap();
assert_eq!(d.page_count(), 4);
assert_eq!(d.spreads.len(), 3);
d.delete_pages(&[1, 2]).unwrap();
assert_eq!(d.page_count(), 2);
assert_eq!(d.spreads.iter().map(|s| s.pages.len()).collect::<Vec<_>>(), vec![1, 1]);
assert!(d.delete_pages(&[0, 1]).is_err());
}
#[test]
fn items_travel_with_pages() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
// Put a frame on page 3 (spread 1, page index 1).
let lid = d.default_layer();
let r = designcraft_geom::Rect::new(612.0 + 100.0, 100.0, 612.0 + 200.0, 200.0);
let (id, _) = d.add_text_frame(crate::SpreadRef::Doc(1), r, lid, "hello", Default::default()).unwrap();
d.move_page(2, 0).unwrap();
// The frame's page is now page 1 (spread 0, single right page at x=0).
let loc = d.find(id).unwrap();
assert_eq!(loc.spread, crate::SpreadRef::Doc(0));
let b = d.item(id).unwrap().bounds();
assert!((b.x0 - 100.0).abs() < 1e-9, "{b:?}");
d.check().unwrap();
}
#[test]
fn presets_and_parents() {
let nd = NewDocument::from_preset("A4").unwrap();
assert!((nd.width - 595.2756).abs() < 1e-3);
let mut d = Document::new(&nd);
assert_eq!(d.next_parent_prefix(), "B");
let b = d.add_parent("B", "Parent", 2, 12.0, Margins::uniform(36.0));
d.apply_parent(&[0], Some(b)).unwrap();
assert_eq!(d.parent_page_for(0), Some((1, 1)));
d.apply_parent(&[0], None).unwrap();
assert_eq!(d.parent_page_for(0), None);
}
}

371
crates/doc/src/edit.rs Normal file
View File

@ -0,0 +1,371 @@
//! Locating and editing items, threading, and story management.
use std::sync::Arc;
use designcraft_geom::{Point, Rect, shapes};
use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, LayerId, StoryId};
use crate::item::{Content, Item, Shape, TextFrame, TextFrameOptions};
use crate::page::Spread;
use crate::story::{CharRun, ParaFormat, Story};
use crate::{DocError, Document, Result};
/// Which spread list an item lives in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "index")]
pub enum SpreadRef {
Doc(usize),
Parent(usize),
}
/// Indices from `spread.items` down through nested groups.
pub type ItemPath = Vec<usize>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ItemLoc {
pub spread: SpreadRef,
pub path: ItemPath,
}
impl ItemLoc {
/// Top-level index in the spread.
pub fn top(&self) -> usize {
self.path[0]
}
}
fn find_in(items: &[Arc<Item>], id: ItemId, path: &mut ItemPath) -> bool {
for (i, it) in items.iter().enumerate() {
path.push(i);
if it.id == id || find_in(it.children(), id, path) {
return true;
}
path.pop();
}
false
}
impl Document {
pub fn spread(&self, r: SpreadRef) -> Option<&Spread> {
match r {
SpreadRef::Doc(i) => self.spreads.get(i).map(|s| &**s),
SpreadRef::Parent(i) => self.parents.get(i).map(|s| &**s),
}
}
pub fn spread_mut(&mut self, r: SpreadRef) -> Option<&mut Spread> {
match r {
SpreadRef::Doc(i) => self.spreads.get_mut(i).map(Arc::make_mut),
SpreadRef::Parent(i) => self.parents.get_mut(i).map(Arc::make_mut),
}
}
/// All spread refs: document spreads then parents.
pub fn spread_refs(&self) -> impl Iterator<Item = SpreadRef> {
(0..self.spreads.len()).map(SpreadRef::Doc).chain((0..self.parents.len()).map(SpreadRef::Parent))
}
pub fn find(&self, id: ItemId) -> Option<ItemLoc> {
for r in self.spread_refs() {
let mut path = Vec::new();
if find_in(&self.spread(r)?.items, id, &mut path) {
return Some(ItemLoc { spread: r, path });
}
}
None
}
pub fn item_at(&self, loc: &ItemLoc) -> Option<&Item> {
let sp = self.spread(loc.spread)?;
let mut it: &Item = sp.items.get(*loc.path.first()?)?;
for &i in &loc.path[1..] {
it = it.children().get(i)?;
}
Some(it)
}
pub fn item(&self, id: ItemId) -> Option<&Item> {
self.item_at(&self.find(id)?)
}
pub fn item_mut_at(&mut self, loc: &ItemLoc) -> Option<&mut Item> {
let sp = self.spread_mut(loc.spread)?;
let mut it: &mut Item = Arc::make_mut(sp.items.get_mut(*loc.path.first()?)?);
for &i in &loc.path[1..] {
it = Arc::make_mut(it.children_mut()?.get_mut(i)?);
}
Some(it)
}
pub fn item_mut(&mut self, id: ItemId) -> Option<&mut Item> {
let loc = self.find(id)?;
self.item_mut_at(&loc)
}
/// The top-level item (group root) containing `id`.
pub fn top_level_of(&self, id: ItemId) -> Option<ItemId> {
let loc = self.find(id)?;
Some(self.spread(loc.spread)?.items[loc.top()].id)
}
/// Spread-space transform of the parent group chain of the item at `loc` (identity at top level).
pub fn parent_xf(&self, loc: &ItemLoc) -> designcraft_geom::Affine {
let mut xf = designcraft_geom::Affine::IDENTITY;
if let Some(sp) = self.spread(loc.spread) {
let mut items = &sp.items;
for &i in &loc.path[..loc.path.len().saturating_sub(1)] {
let it = &items[i];
xf *= it.xf;
items = match &it.content {
Content::Group { items } => items,
_ => break,
};
}
}
xf
}
/// Insert an item into a spread at `index` (None = front).
pub fn insert_item(&mut self, r: SpreadRef, item: Item, index: Option<usize>) -> Result<ItemId> {
let id = item.id;
let sp = self.spread_mut(r).ok_or_else(|| DocError::Invalid(format!("no spread {r:?}")))?;
let i = index.unwrap_or(sp.items.len()).min(sp.items.len());
sp.items.insert(i, Arc::new(item));
Ok(id)
}
/// Remove an item (and its descendants) from the document. Text frames leave their threads;
/// stories that lose their last frame are deleted.
pub fn remove_item(&mut self, id: ItemId) -> Result<Arc<Item>> {
let loc = self.find(id).ok_or(DocError::NoItem(id))?;
let removed = {
let sp = self.spread_mut(loc.spread).ok_or(DocError::NoItem(id))?;
if loc.path.len() == 1 {
sp.items.remove(loc.path[0])
} else {
let mut it: &mut Item = Arc::make_mut(&mut sp.items[loc.path[0]]);
for &i in &loc.path[1..loc.path.len() - 1] {
it = Arc::make_mut(&mut it.children_mut().ok_or(DocError::NoItem(id))?[i]);
}
it.children_mut().ok_or(DocError::NoItem(id))?.remove(*loc.path.last().expect("non-empty"))
}
};
let mut frames = Vec::new();
removed.walk(&mut |i| {
if let Some(t) = i.text_frame() {
frames.push((i.id, t.story));
}
});
for (fid, sid) in frames {
let empty = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
st.frames.retain(|f| *f != fid);
st.frames.is_empty()
};
if empty {
self.stories.remove(&sid);
}
}
Ok(removed)
}
/// Create a text frame with a new (or given) story at `rect` on spread `r`.
pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> {
let id = ItemId(self.alloc());
let sid = StoryId(self.alloc());
let mut story = Story::with_text(sid, text, para);
story.frames.push(id);
self.stories.insert(sid, Arc::new(story));
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
item.object_style = self.styles.default_text_frame.clone();
if let Some(os) = self.styles.object_style(&self.styles.default_text_frame).cloned() {
if let Some(f) = os.fill {
item.fill = f;
}
if let Some(s) = os.stroke {
item.stroke = s;
}
}
item.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.insert_item(r, item, None)?;
Ok((id, sid))
}
/// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text,
/// that text is appended to the story (as a new paragraph) and its old story is removed.
pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?;
// Convert unassigned/graphic-less frames into text frames.
let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() {
Content::Text(t) => Some(t.story),
Content::Unassigned => None,
_ => return Err(DocError::Invalid("can only thread to text or empty frames".into())),
};
if ts == Some(fs) {
return Err(DocError::Invalid("frames are already in the same story".into()));
}
if let Some(ts) = ts {
let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?;
let other = Arc::unwrap_or_clone(other);
// Frames of the other story after `to` follow along; frames before it are left as empty new stories.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let before: Vec<ItemId> = other.frames[..pos_to].to_vec();
let after: Vec<ItemId> = other.frames[pos_to..].to_vec();
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
if !other.text.is_empty() {
st.text.push('\n');
st.text.push_str(&other.text);
st.paras.extend(other.paras.iter().cloned());
// Char runs: '\n' takes the last run's format, then the other story's runs.
if let Some(last) = st.chars.last_mut() {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
} else {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
}
if let Some(st) = self.story_mut(fs) {
st.rev += 1;
}
Ok(())
}
/// Break the thread after `frame`: the following frames become a new empty story (the text
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let tail = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
let tail = st.frames.split_off(p + 1);
st.rev += 1;
tail
};
if tail.is_empty() {
return Ok(());
}
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = tail.clone();
self.stories.insert(nid, Arc::new(ns));
for f in tail {
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
Ok(())
}
/// Next / previous frame in a thread.
pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
let p = st.frames.iter().position(|f| *f == frame)?;
st.frames.get(p + 1).copied()
}
pub fn prev_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
let p = st.frames.iter().position(|f| *f == frame)?;
p.checked_sub(1).map(|i| st.frames[i])
}
/// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space).
/// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline.
pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option<ItemId> {
let sp = self.spreads.get(si)?;
let mut items: Vec<&Arc<Item>> = sp.items.iter().collect();
items.sort_by_key(|i| self.layer_rank(i.layer));
for it in items.into_iter().rev() {
let layer = self.layer(it.layer);
if it.hidden || layer.is_some_and(|l| !l.visible || l.locked) {
continue;
}
if item_hit(it, p, tol) {
return Some(it.id);
}
}
None
}
/// Every item id (pre-order) on document spreads.
pub fn all_items(&self) -> Vec<ItemId> {
let mut v = Vec::new();
for sp in &self.spreads {
for it in &sp.items {
it.walk(&mut |i| v.push(i.id));
}
}
v
}
/// Plain story text with frame/story stats (for agents).
pub fn story_summary(&self, sid: StoryId) -> Option<serde_json::Value> {
let st = self.story(sid)?;
Some(serde_json::json!({
"id": sid.0,
"length": st.text.len(),
"paragraphs": st.paras.len(),
"frames": st.frames.iter().map(|f| f.0).collect::<Vec<_>>(),
"text": st.text,
}))
}
/// Replace a story's text and formatting wholesale (agents / import).
pub fn set_story_text(&mut self, sid: StoryId, text: &str) -> Result<()> {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let para = st.paras.first().cloned().unwrap_or_default();
let fmt = st.chars.first().map(|r| r.format.clone()).unwrap_or_default();
let n = text.matches('\n').count() + 1;
st.text = text.to_string();
st.paras = vec![para; n];
st.chars = vec![CharRun { len: text.len(), format: fmt }];
st.rev += 1;
Ok(())
}
}
/// Does `p` (in the item's parent space) hit `it`?
pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
if let Content::Group { items } = &it.content {
let inner = it.xf.inverse() * p;
return items.iter().any(|c| item_hit(c, inner, tol));
}
let inner = it.xf.inverse() * p;
let bp = it.path.to_bezpath();
let area = !it.fill.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_)) || it.path.is_closed() && it.shape != Shape::Path;
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
return true;
}
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
}

599
crates/doc/src/item.rs Normal file
View File

@ -0,0 +1,599 @@
//! Page items: frames (text / graphic / unassigned), paths, lines and groups.
use std::sync::Arc;
use designcraft_color::BlendMode;
use designcraft_geom::corners::CornerOptions;
use designcraft_geom::{Affine, PathData, Point, Rect};
use serde::{Deserialize, Serialize};
use crate::ids::{AssetId, ItemId, LayerId, StoryId};
/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Fill {
pub swatch: String,
pub tint: f32,
/// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds).
#[serde(skip_serializing_if = "Option::is_none")]
pub gradient_angle: Option<f64>,
pub overprint: bool,
}
impl Default for Fill {
fn default() -> Self {
Fill::none()
}
}
impl Fill {
pub fn none() -> Self {
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, overprint: false }
}
pub fn swatch(name: &str) -> Self {
Fill { swatch: name.into(), ..Fill::none() }
}
pub fn is_none(&self) -> bool {
self.swatch == designcraft_color::swatch::NONE
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StrokeAlign {
#[default]
Center,
Inside,
Outside,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Cap {
#[default]
Butt,
Round,
Projecting,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Join {
#[default]
Miter,
Round,
Bevel,
}
/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum StrokeType {
#[default]
Solid,
Dashed {
pattern: Vec<f64>,
},
Dotted,
ThickThin,
ThinThick,
ThinThin,
ThickThick,
ThinThickThin,
ThickThinThick,
Wavy,
Hashed,
}
impl StrokeType {
pub fn label(&self) -> &'static str {
match self {
StrokeType::Solid => "Solid",
StrokeType::Dashed { .. } => "Dashed",
StrokeType::Dotted => "Dotted",
StrokeType::ThickThin => "Thick - Thin",
StrokeType::ThinThick => "Thin - Thick",
StrokeType::ThinThin => "Thin - Thin",
StrokeType::ThickThick => "Thick - Thick",
StrokeType::ThinThickThin => "Thin - Thick - Thin",
StrokeType::ThickThinThick => "Thick - Thin - Thick",
StrokeType::Wavy => "Wavy",
StrokeType::Hashed => "Straight Hash",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Arrowhead {
#[default]
None,
SimpleWide,
Simple,
Triangle,
TriangleWide,
Barbed,
Curved,
Circle,
CircleSolid,
Square,
SquareSolid,
Bar,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Stroke {
pub swatch: String,
pub tint: f32,
pub weight: f64,
#[serde(rename = "type")]
pub kind: StrokeType,
pub align: StrokeAlign,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
pub start: Arrowhead,
pub end: Arrowhead,
/// Gap colour for dashed/striped strokes.
pub gap_swatch: String,
pub gap_tint: f32,
pub overprint: bool,
}
impl Default for Stroke {
/// InDesign's default for drawn shapes: 1 pt black.
fn default() -> Self {
Stroke {
swatch: designcraft_color::swatch::BLACK.into(),
tint: 1.0,
weight: 1.0,
kind: StrokeType::Solid,
align: StrokeAlign::Center,
cap: Cap::Butt,
join: Join::Miter,
miter_limit: 4.0,
start: Arrowhead::None,
end: Arrowhead::None,
gap_swatch: designcraft_color::swatch::NONE.into(),
gap_tint: 1.0,
overprint: false,
}
}
}
impl Stroke {
pub fn none() -> Self {
Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() }
}
pub fn is_none(&self) -> bool {
self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VerticalJustification {
#[default]
Top,
Center,
Bottom,
Justify,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FirstBaseline {
#[default]
Ascent,
CapHeight,
Leading,
XHeight,
Fixed,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AutoSize {
#[default]
Off,
HeightOnly,
WidthOnly,
HeightAndWidth,
HeightAndWidthProportionally,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ColumnsKind {
#[default]
FixedNumber,
FixedWidth,
FlexibleWidth,
}
/// Text Frame Options.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TextFrameOptions {
pub columns: u32,
pub columns_kind: ColumnsKind,
pub gutter: f64,
/// Column width for fixed/flexible width columns.
pub column_width: f64,
pub balance_columns: bool,
/// Inset spacing: top, left, bottom, right.
pub inset: [f64; 4],
pub vertical_justification: VerticalJustification,
pub vj_paragraph_spacing_limit: f64,
pub first_baseline: FirstBaseline,
pub first_baseline_min: f64,
pub ignore_wrap: bool,
pub auto_size: AutoSize,
/// Reference point (0..9) that stays fixed when auto-sizing.
pub auto_size_ref: u8,
pub column_rule: bool,
pub column_rule_weight: f64,
pub column_rule_color: String,
/// Use a custom baseline grid for this frame.
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_grid: Option<(f64, f64)>,
}
impl Default for TextFrameOptions {
fn default() -> Self {
TextFrameOptions {
columns: 1,
columns_kind: ColumnsKind::FixedNumber,
gutter: 12.0,
column_width: 0.0,
balance_columns: false,
inset: [0.0; 4],
vertical_justification: VerticalJustification::Top,
vj_paragraph_spacing_limit: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
ignore_wrap: false,
auto_size: AutoSize::Off,
auto_size_ref: 1,
column_rule: false,
column_rule_weight: 1.0,
column_rule_color: designcraft_color::swatch::BLACK.into(),
baseline_grid: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WrapMode {
#[default]
None,
BoundingBox,
Contour,
JumpObject,
JumpToNextColumn,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WrapSide {
#[default]
BothSides,
RightSide,
LeftSide,
TowardsSpine,
AwayFromSpine,
LargestArea,
}
/// Text Wrap panel settings.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TextWrap {
pub mode: WrapMode,
/// Top, left, bottom, right offsets.
pub offsets: [f64; 4],
pub invert: bool,
pub side: WrapSide,
}
/// Frame fitting options (Object → Fitting → Frame Fitting Options).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Fitting {
#[default]
None,
FillProportionally,
FitProportionally,
FitContentToFrame,
CenterContent,
}
/// A placed graphic inside a frame.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
pub asset: AssetId,
/// Natural size of the graphic in points (pixels at its resolution, or PDF page size).
pub size: (f64, f64),
/// Graphic space (0,0)-(w,h) → frame inner space.
pub xf: Affine,
#[serde(default)]
pub auto_fit: Fitting,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFrame {
pub story: StoryId,
#[serde(default)]
pub options: TextFrameOptions,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum Content {
#[default]
Unassigned,
Text(TextFrame),
Graphic(Graphic),
Group {
items: Vec<Arc<Item>>,
},
}
/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Shape {
#[default]
Rectangle,
Oval,
Polygon,
GraphicLine,
Path,
Group,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DropShadow {
pub on: bool,
pub color: String,
pub opacity: f32,
pub angle: f64,
pub distance: f64,
pub size: f64,
pub spread: f64,
}
impl Default for DropShadow {
fn default() -> Self {
DropShadow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 135.0, distance: 7.0, size: 5.0, spread: 0.0 }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Effects {
pub drop_shadow: DropShadow,
/// Basic feather width (0 = off).
pub feather: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Item {
pub id: ItemId,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
pub layer: LayerId,
#[serde(default)]
pub shape: Shape,
/// Geometry in the item's inner coordinate space.
pub path: PathData,
/// Inner space → spread space.
#[serde(default = "identity")]
pub xf: Affine,
#[serde(default)]
pub fill: Fill,
#[serde(default = "Stroke::none")]
pub stroke: Stroke,
#[serde(default)]
pub corners: CornerOptions,
#[serde(default = "one")]
pub opacity: f32,
#[serde(default)]
pub blend: BlendMode,
#[serde(default)]
pub effects: Effects,
#[serde(default)]
pub wrap: TextWrap,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub nonprinting: bool,
#[serde(default)]
pub object_style: String,
#[serde(default)]
pub content: Content,
/// For items on a document page that override a parent-page item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overrides: Option<ItemId>,
}
fn identity() -> Affine {
Affine::IDENTITY
}
fn one() -> f32 {
1.0
}
impl Item {
pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self {
Item {
id,
name: String::new(),
layer,
shape,
path,
xf: Affine::IDENTITY,
fill: Fill::none(),
stroke: Stroke::none(),
corners: CornerOptions::default(),
opacity: 1.0,
blend: BlendMode::default(),
effects: Effects::default(),
wrap: TextWrap::default(),
locked: false,
hidden: false,
nonprinting: false,
object_style: crate::styles::NO_OBJECT_STYLE.into(),
content: Content::Unassigned,
overrides: None,
}
}
pub fn is_text_frame(&self) -> bool {
matches!(self.content, Content::Text(_))
}
pub fn text_frame(&self) -> Option<&TextFrame> {
match &self.content {
Content::Text(t) => Some(t),
_ => None,
}
}
pub fn text_frame_mut(&mut self) -> Option<&mut TextFrame> {
match &mut self.content {
Content::Text(t) => Some(t),
_ => None,
}
}
pub fn graphic(&self) -> Option<&Graphic> {
match &self.content {
Content::Graphic(g) => Some(g),
_ => None,
}
}
pub fn children(&self) -> &[Arc<Item>] {
match &self.content {
Content::Group { items } => items,
_ => &[],
}
}
pub fn children_mut(&mut self) -> Option<&mut Vec<Arc<Item>>> {
match &mut self.content {
Content::Group { items } => Some(items),
_ => None,
}
}
/// Inner-space bounds of the path (the frame's "geometric bounds" before transform).
pub fn inner_bounds(&self) -> Rect {
self.path.bounds().unwrap_or(Rect::ZERO)
}
/// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union).
pub fn bounds(&self) -> Rect {
if let Content::Group { items } = &self.content {
let mut r: Option<Rect> = None;
for c in items {
let b = self.xf.transform_rect_bbox(c.bounds());
r = Some(r.map_or(b, |r| r.union(b)));
}
return r.unwrap_or(Rect::ZERO);
}
self.path.transformed(self.xf).bounds().unwrap_or(Rect::ZERO)
}
/// Visible bounds including stroke weight (approximate for inside/outside alignment).
pub fn visible_bounds(&self) -> Rect {
let b = self.bounds();
if self.stroke.is_none() {
return b;
}
let w = match self.stroke.align {
StrokeAlign::Center => self.stroke.weight / 2.0,
StrokeAlign::Inside => 0.0,
StrokeAlign::Outside => self.stroke.weight,
};
b.inflate(w, w)
}
/// The frame's text area (inner space) after inset.
pub fn text_area(&self) -> Rect {
let r = self.inner_bounds();
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]);
Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0]))
}
/// Spread-space centre.
pub fn center(&self) -> Point {
self.bounds().center()
}
/// The Layers-panel label: `<rectangle>`, the first words of a text frame, the placed file name…
pub fn default_label(&self) -> &'static str {
match (&self.content, self.shape) {
(Content::Group { .. }, _) => "<group>",
(Content::Text(_), _) => "<text frame>",
(Content::Graphic(_), _) => "<image>",
(_, Shape::Rectangle) => "<rectangle>",
(_, Shape::Oval) => "<ellipse>",
(_, Shape::Polygon) => "<polygon>",
(_, Shape::GraphicLine) => "<line>",
(_, Shape::Path) | (_, Shape::Group) => "<path>",
}
}
/// Visit this item and descendants (pre-order).
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Item)) {
f(self);
for c in self.children() {
c.walk(f);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_geom::shapes;
#[test]
fn bounds_and_text_area() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
it.xf = Affine::translate((10.0, 20.0));
assert_eq!(it.bounds(), Rect::new(10.0, 20.0, 110.0, 70.0));
it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } });
assert_eq!(it.text_area(), Rect::new(6.0, 5.0, 92.0, 43.0));
it.stroke = Stroke { weight: 4.0, ..Stroke::default() };
assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0));
}
#[test]
fn group_bounds_union_children() {
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
let b = Item::new(ItemId(2), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(20.0, 20.0, 30.0, 40.0)));
let mut g = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default());
g.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] };
assert_eq!(g.bounds(), Rect::new(0.0, 0.0, 30.0, 40.0));
let mut n = 0;
g.walk(&mut |_| n += 1);
assert_eq!(n, 3);
}
#[test]
fn serde_roundtrip() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)));
it.fill = Fill::swatch("[Black]");
it.content = Content::Text(TextFrame { story: StoryId(9), options: TextFrameOptions::default() });
let s = serde_json::to_string(&it).unwrap();
let back: Item = serde_json::from_str(&s).unwrap();
assert_eq!(it, back);
}
}

387
crates/doc/src/lib.rs Normal file
View File

@ -0,0 +1,387 @@
//! The DesignCraft document model (pure data + serde).
//!
//! A [`Document`] holds spreads of pages, parent ("master") spreads, layers, stories, styles,
//! swatches, sections and embedded assets. Spreads, items and stories are `Arc`-shared so an
//! edit clones only what it touches and undo snapshots are O(1).
//!
//! Coordinates are points, y down. Each spread has its own space: pages sit side by side from
//! x = 0 with their tops at y = 0. Items live in spread space via `Item::xf`.
#![forbid(unsafe_code)]
pub mod attrs;
pub mod build;
mod edit;
pub mod ids;
pub mod item;
pub mod page;
pub mod selection;
pub mod story;
pub mod styles;
use std::collections::BTreeMap;
use std::sync::Arc;
pub use attrs::*;
pub use designcraft_color as color;
pub use designcraft_geom as geom;
pub use edit::{ItemLoc, ItemPath, SpreadRef};
pub use ids::*;
pub use item::*;
pub use page::*;
pub use selection::*;
pub use story::*;
pub use styles::*;
use designcraft_color::Swatch;
use designcraft_geom::Unit;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
#[error("no such item {0}")]
NoItem(ItemId),
#[error("no such story {0}")]
NoStory(StoryId),
#[error("no such page {0}")]
NoPage(usize),
#[error("no such layer {0}")]
NoLayer(LayerId),
#[error("{0}")]
Invalid(String),
}
pub type Result<T> = std::result::Result<T, DocError>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Intent {
#[default]
Print,
Web,
Mobile,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GridRelative {
#[default]
TopOfPage,
TopMargin,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct BaselineGrid {
pub start: f64,
pub increment: f64,
pub relative_to: GridRelative,
pub color: [u8; 3],
/// Hidden below this zoom (1.0 = 100%).
pub view_threshold: f64,
}
impl Default for BaselineGrid {
fn default() -> Self {
BaselineGrid { start: 36.0, increment: 12.0, relative_to: GridRelative::TopOfPage, color: [140, 205, 230], view_threshold: 0.75 }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocumentGrid {
pub horizontal: f64,
pub vertical: f64,
pub subdivisions: u32,
pub color: [u8; 3],
pub in_back: bool,
}
impl Default for DocumentGrid {
fn default() -> Self {
DocumentGrid { horizontal: 72.0, vertical: 72.0, subdivisions: 8, color: [200, 200, 200], in_back: true }
}
}
/// File → Document Setup + the guide/grid/unit preferences stored with the document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocSettings {
pub intent: Intent,
pub page_width: f64,
pub page_height: f64,
pub facing_pages: bool,
pub primary_text_frame: bool,
/// Bleed: top, bottom, inside, outside.
pub bleed: [f64; 4],
pub slug: [f64; 4],
pub horizontal_units: Unit,
pub vertical_units: Unit,
pub baseline_grid: BaselineGrid,
pub grid: DocumentGrid,
/// Pasteboard extent around spreads (horizontal, vertical).
pub pasteboard: (f64, f64),
pub margin_color: [u8; 3],
pub column_color: [u8; 3],
pub bleed_color: [u8; 3],
pub slug_color: [u8; 3],
pub keyboard_increment: f64,
}
impl Default for DocSettings {
fn default() -> Self {
DocSettings {
intent: Intent::Print,
page_width: 612.0,
page_height: 792.0,
facing_pages: true,
primary_text_frame: false,
bleed: [0.0; 4],
slug: [0.0; 4],
horizontal_units: Unit::Picas,
vertical_units: Unit::Picas,
baseline_grid: BaselineGrid::default(),
grid: DocumentGrid::default(),
pasteboard: (72.0, 72.0),
margin_color: [255, 0, 255],
column_color: [148, 112, 255],
bleed_color: [255, 0, 0],
slug_color: [60, 120, 255],
keyboard_increment: 1.0,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Layer {
pub id: LayerId,
pub name: String,
/// Selection / frame-edge colour.
pub color: [u8; 3],
#[serde(default = "yes")]
pub visible: bool,
#[serde(default)]
pub locked: bool,
#[serde(default = "yes")]
pub printable: bool,
#[serde(default = "yes")]
pub show_guides: bool,
#[serde(default)]
pub suppress_wrap_when_hidden: bool,
}
fn yes() -> bool {
true
}
/// InDesign's layer colour sequence (names are the conventional colour names).
pub const LAYER_COLORS: &[(&str, [u8; 3])] = &[
("Light Blue", [79, 153, 255]),
("Red", [255, 0, 0]),
("Green", [79, 255, 79]),
("Blue", [0, 0, 255]),
("Yellow", [255, 255, 79]),
("Magenta", [255, 79, 255]),
("Cyan", [0, 255, 255]),
("Gray", [128, 128, 128]),
("Black", [0, 0, 0]),
("Orange", [255, 102, 0]),
("Dark Green", [0, 84, 0]),
("Teal", [0, 153, 153]),
("Tan", [204, 153, 102]),
("Brown", [153, 51, 0]),
("Violet", [153, 51, 255]),
("Gold", [255, 153, 0]),
];
/// An embedded file (placed image / PDF). Bytes are stored next to the JSON in the native format.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Asset {
pub id: AssetId,
pub name: String,
pub mime: String,
/// Original location on disk (Links panel), if linked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(skip)]
pub data: Arc<Vec<u8>>,
/// Pixel size for raster images.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pixels: Option<(u32, u32)>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Document {
pub title: String,
pub settings: DocSettings,
pub spreads: Vec<Arc<Spread>>,
pub parents: Vec<Arc<Spread>>,
/// Top of the list = frontmost layer (Layers panel order).
pub layers: Vec<Layer>,
pub stories: BTreeMap<StoryId, Arc<Story>>,
pub styles: Arc<Styles>,
pub swatches: Vec<Swatch>,
pub sections: Vec<Section>,
#[serde(default)]
pub assets: BTreeMap<AssetId, Arc<Asset>>,
pub next_id: u64,
}
impl Document {
/// Allocate a fresh id.
pub fn alloc(&mut self) -> u64 {
self.next_id += 1;
self.next_id
}
pub fn page_count(&self) -> usize {
self.spreads.iter().map(|s| s.pages.len()).sum()
}
/// (spread index, page index within spread) for an absolute page index.
pub fn page_loc(&self, abs: usize) -> Option<(usize, usize)> {
let mut n = 0;
for (si, s) in self.spreads.iter().enumerate() {
if abs < n + s.pages.len() {
return Some((si, abs - n));
}
n += s.pages.len();
}
None
}
/// Absolute index of the first page of spread `si`.
pub fn first_page_of_spread(&self, si: usize) -> usize {
self.spreads[..si.min(self.spreads.len())].iter().map(|s| s.pages.len()).sum()
}
pub fn page(&self, abs: usize) -> Option<&Page> {
let (s, p) = self.page_loc(abs)?;
self.spreads[s].pages.get(p)
}
/// Iterate all pages with their absolute index and spread index.
pub fn pages(&self) -> impl Iterator<Item = (usize, usize, &Page)> {
self.spreads.iter().enumerate().flat_map(|(si, s)| s.pages.iter().map(move |p| (si, p))).enumerate().map(|(i, (si, p))| (i, si, p))
}
/// The displayed page name ("1", "iv", "A-3"…) for an absolute page index, per sections.
pub fn page_name(&self, abs: usize) -> String {
let mut sec: Option<&Section> = None;
for s in &self.sections {
if s.start <= abs && sec.is_none_or(|c| s.start >= c.start) {
sec = Some(s);
}
}
let Some(sec) = sec else { return (abs + 1).to_string() };
// Numbers continue from the previous section when start_number is None.
let start_number = match sec.start_number {
Some(n) => n,
None => {
let prev: Vec<&Section> = self.sections.iter().filter(|s| s.start < sec.start).collect();
if prev.is_empty() {
sec.start as u32 + 1
} else {
// Approximate continuation: absolute position + 1.
sec.start as u32 + 1
}
}
};
let n = start_number + (abs - sec.start) as u32;
let num = sec.style.format(n);
if sec.include_prefix { format!("{}{}", sec.prefix, num) } else { num }
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
self.layers.iter().find(|l| l.id == id)
}
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
self.layers.iter_mut().find(|l| l.id == id)
}
/// Stacking rank of a layer: 0 = backmost.
pub fn layer_rank(&self, id: LayerId) -> usize {
self.layers.iter().position(|l| l.id == id).map(|i| self.layers.len() - 1 - i).unwrap_or(0)
}
pub fn default_layer(&self) -> LayerId {
self.layers.first().map(|l| l.id).unwrap_or_default()
}
pub fn story(&self, id: StoryId) -> Option<&Story> {
self.stories.get(&id).map(|s| &**s)
}
pub fn story_mut(&mut self, id: StoryId) -> Option<&mut Story> {
self.stories.get_mut(&id).map(Arc::make_mut)
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches.iter().find(|s| s.name == name)
}
/// Resolve a swatch reference to a display colour.
pub fn resolve_color(&self, swatch: &str, tint: f32) -> Option<designcraft_color::Color> {
designcraft_color::swatch::resolve(&self.swatches, swatch, tint)
}
/// Validate structural invariants (ids unique, threads consistent, stories well-formed).
pub fn check(&self) -> Result<()> {
let mut ids = std::collections::HashSet::new();
for sp in self.spreads.iter().chain(self.parents.iter()) {
for it in &sp.items {
let mut dup = None;
it.walk(&mut |i| {
if !ids.insert(i.id.0) {
dup = Some(i.id);
}
});
if let Some(d) = dup {
return Err(DocError::Invalid(format!("duplicate item id {d}")));
}
}
}
for (sid, st) in &self.stories {
st.check().map_err(DocError::Invalid)?;
if st.id != *sid {
return Err(DocError::Invalid(format!("story key {sid} != id {}", st.id)));
}
for f in &st.frames {
let item = self.find(*f).ok_or(DocError::NoItem(*f))?;
match self.item_at(&item).and_then(|i| i.text_frame().map(|t| t.story)) {
Some(s) if s == *sid => {}
other => return Err(DocError::Invalid(format!("frame {f} in story {sid} points at {other:?}"))),
}
}
}
// Every text frame is in its story's thread.
for sp in self.spreads.iter().chain(self.parents.iter()) {
for it in &sp.items {
let mut bad = None;
it.walk(&mut |i| {
if let Some(t) = i.text_frame()
&& !self.stories.get(&t.story).is_some_and(|s| s.frames.contains(&i.id))
{
bad = Some(i.id);
}
});
if let Some(b) = bad {
return Err(DocError::Invalid(format!("text frame {b} not in its story's thread")));
}
}
}
if self.next_id < ids.iter().copied().max().unwrap_or(0) {
return Err(DocError::Invalid("next_id behind existing ids".into()));
}
Ok(())
}
}
#[cfg(test)]
mod tests;
impl Document {
/// Mutable styles (copy-on-write).
pub fn styles_mut(&mut self) -> &mut Styles {
Arc::make_mut(&mut self.styles)
}
}

262
crates/doc/src/page.rs Normal file
View File

@ -0,0 +1,262 @@
//! Spreads, pages, margins, columns, guides; parent ("master") spreads; sections.
use std::sync::Arc;
use designcraft_geom::Rect;
use serde::{Deserialize, Serialize};
use crate::attrs::NumberStyle;
use crate::ids::{ItemId, PageId, SpreadId};
use crate::item::Item;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Margins {
pub top: f64,
pub bottom: f64,
/// Inside (facing pages) / left.
pub inside: f64,
/// Outside (facing pages) / right.
pub outside: f64,
}
impl Margins {
pub fn uniform(v: f64) -> Self {
Margins { top: v, bottom: v, inside: v, outside: v }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Columns {
pub count: u32,
pub gutter: f64,
/// Custom column positions (after dragging column guides); `None` = evenly spaced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub positions: Option<Vec<f64>>,
}
impl Default for Columns {
fn default() -> Self {
Columns { count: 1, gutter: 12.0, positions: None }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Orientation {
Horizontal,
Vertical,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Guide {
pub orientation: Orientation,
/// Spread coordinate (x for vertical guides, y for horizontal).
pub position: f64,
/// Spread guides cross the whole pasteboard; page guides only their page.
#[serde(default)]
pub spread: bool,
#[serde(default)]
pub locked: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PageSide {
#[default]
Single,
Left,
Right,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Page {
pub id: PageId,
pub width: f64,
pub height: f64,
/// Position of the page's left edge in spread coordinates (top is y = 0).
pub x: f64,
pub margins: Margins,
pub columns: Columns,
/// Applied parent spread (`None` = [None]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<SpreadId>,
#[serde(default)]
pub side: PageSide,
/// Parent items overridden on this page (hidden from the parent rendering).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overridden: Vec<ItemId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<Guide>,
/// Show parent items on this page.
#[serde(default = "yes")]
pub show_parent_items: bool,
}
fn yes() -> bool {
true
}
impl Page {
pub fn bounds(&self) -> Rect {
Rect::new(self.x, 0.0, self.x + self.width, self.height)
}
/// Left and right margins resolved for the page side (inside is at the spine).
pub fn left_right_margins(&self) -> (f64, f64) {
match self.side {
PageSide::Left => (self.margins.outside, self.margins.inside),
_ => (self.margins.inside, self.margins.outside),
}
}
/// The live area inside the margins.
pub fn margin_rect(&self) -> Rect {
let (l, r) = self.left_right_margins();
let b = self.bounds();
Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top))
}
/// Column rects within the margins.
pub fn column_rects(&self) -> Vec<Rect> {
let m = self.margin_rect();
column_rects(m, self.columns.count.max(1), self.columns.gutter)
}
}
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
let n = n.max(1) as usize;
let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0);
(0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect()
}
/// Parent spread metadata.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParentInfo {
/// `A`, `B`…
pub prefix: String,
/// `Parent`.
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub based_on: Option<SpreadId>,
}
impl ParentInfo {
pub fn label(&self) -> String {
format!("{}-{}", self.prefix, self.name)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Spread {
pub id: SpreadId,
pub pages: Vec<Page>,
/// Items in z-order (last = front). Coordinates are spread space.
pub items: Vec<Arc<Item>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<ParentInfo>,
#[serde(default = "yes")]
pub allow_shuffle: bool,
}
impl Spread {
/// Union of the page rects.
pub fn bounds(&self) -> Rect {
self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO)
}
/// x of the spine (between left and right pages) or the page centre for single pages.
pub fn spine_x(&self) -> f64 {
match self.pages.iter().position(|p| p.side == PageSide::Right) {
Some(i) => self.pages[i].x,
None => self.bounds().center().x,
}
}
/// Index of the page under spread x (nearest page for the pasteboard).
pub fn page_at_x(&self, x: f64) -> Option<usize> {
if self.pages.is_empty() {
return None;
}
self.pages
.iter()
.position(|p| x >= p.x && x < p.x + p.width)
.or_else(|| if x < self.pages[0].x { Some(0) } else { Some(self.pages.len() - 1) })
}
/// Lay the pages out left to right with no gaps, starting at x = 0.
pub fn relayout(&mut self) {
let mut x = 0.0;
for p in &mut self.pages {
p.x = x;
x += p.width;
}
}
}
/// A numbering section starting at a document page.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Section {
/// Absolute index of the first page of the section.
pub start: usize,
/// Start numbering at (None = continue from the previous section).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_number: Option<u32>,
#[serde(default)]
pub style: NumberStyle,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub prefix: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub marker: String,
#[serde(default)]
pub include_prefix: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn page(side: PageSide, x: f64) -> Page {
Page {
id: PageId(1),
width: 612.0,
height: 792.0,
x,
margins: Margins { top: 36.0, bottom: 36.0, inside: 54.0, outside: 36.0 },
columns: Columns { count: 2, gutter: 12.0, positions: None },
parent: None,
side,
overridden: vec![],
guides: vec![],
show_parent_items: true,
}
}
#[test]
fn margins_mirror_on_left_pages() {
let l = page(PageSide::Left, 0.0);
let r = page(PageSide::Right, 612.0);
assert_eq!(l.margin_rect(), Rect::new(36.0, 36.0, 558.0, 756.0));
assert_eq!(r.margin_rect(), Rect::new(666.0, 36.0, 1188.0, 756.0));
}
#[test]
fn columns_divide_live_area() {
let p = page(PageSide::Single, 0.0);
let cols = p.column_rects();
assert_eq!(cols.len(), 2);
let total: f64 = cols.iter().map(|c| c.width()).sum::<f64>() + 12.0;
assert!((total - p.margin_rect().width()).abs() < 1e-9);
assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9);
}
#[test]
fn spread_spine_and_hit() {
let s = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0), page(PageSide::Right, 612.0)], items: vec![], parent: None, allow_shuffle: true };
assert_eq!(s.spine_x(), 612.0);
assert_eq!(s.page_at_x(700.0), Some(1));
assert_eq!(s.page_at_x(-50.0), Some(0));
assert_eq!(s.page_at_x(5000.0), Some(1));
}
}

View File

@ -0,0 +1,65 @@
//! What is selected: page items (containers or their content) or a text range in a story.
use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, StoryId};
/// A caret or text range in a story. `anchor` stays put while `focus` moves (Shift-arrows).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextSel {
pub story: StoryId,
pub anchor: usize,
pub focus: usize,
/// The frame the caret is shown in (for carets at frame boundaries).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<ItemId>,
}
impl TextSel {
pub fn caret(story: StoryId, pos: usize) -> Self {
TextSel { story, anchor: pos, focus: pos, frame: None }
}
pub fn range(&self) -> std::ops::Range<usize> {
self.anchor.min(self.focus)..self.anchor.max(self.focus)
}
pub fn is_caret(&self) -> bool {
self.anchor == self.focus
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Selection {
/// Selected items (top-level or, with the Direct Selection tool, nested items).
pub items: Vec<ItemId>,
/// Content selection (graphic inside its frame) instead of the container.
#[serde(default)]
pub content: bool,
/// Direct selection: selected anchors as (item, subpath, anchor).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<(ItemId, usize, usize)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<TextSel>,
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<ItemId>,
}
impl Selection {
pub fn is_empty(&self) -> bool {
self.items.is_empty() && self.text.is_none()
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn items(ids: Vec<ItemId>) -> Self {
Selection { items: ids, ..Default::default() }
}
pub fn text(t: TextSel) -> Self {
Selection { text: Some(t), ..Default::default() }
}
pub fn contains(&self, id: ItemId) -> bool {
self.items.contains(&id)
}
}

View File

@ -211,7 +211,8 @@ impl Story {
/// Apply `f` to the character format of every run intersecting `range` (splitting runs at the
/// range boundaries). With an empty range nothing changes (callers set typing formats instead).
pub fn format_chars(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut CharFormat)) {
let (a, b) = (range.start.min(self.text.len()), range.end.min(self.text.len()));
let a = floor_char_boundary(&self.text, range.start);
let b = floor_char_boundary(&self.text, range.end);
if a >= b {
return;
}

85
crates/doc/src/tests.rs Normal file
View File

@ -0,0 +1,85 @@
use designcraft_geom::Rect;
use super::*;
use crate::build::NewDocument;
fn doc() -> Document {
Document::new(&NewDocument { pages: 2, ..Default::default() })
}
#[test]
fn threading_moves_frames_and_text() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap();
d.thread(a, b).unwrap();
d.check().unwrap();
assert!(d.story(sb).is_none());
let st = d.story(sa).unwrap();
assert_eq!(st.text, "first\nsecond");
assert_eq!(st.frames, vec![a, b]);
assert_eq!(d.next_frame(a), Some(b));
assert_eq!(d.prev_frame(b), Some(a));
d.unthread_after(a).unwrap();
d.check().unwrap();
assert_eq!(d.story(sa).unwrap().frames, vec![a]);
assert_ne!(d.item(b).unwrap().text_frame().unwrap().story, sa);
}
#[test]
fn thread_to_empty_frame_converts_it() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap();
let id = ItemId(d.alloc());
let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0)));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
d.thread(a, id).unwrap();
assert!(d.item(id).unwrap().is_text_frame());
assert_eq!(d.story(sa).unwrap().frames, vec![a, id]);
d.check().unwrap();
assert!(d.thread(a, a).is_err());
}
#[test]
fn removing_last_frame_deletes_story() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap();
d.remove_item(a).unwrap();
assert!(d.story(sa).is_none());
d.check().unwrap();
}
#[test]
fn hit_testing_respects_layers() {
let mut d = doc();
let lid = d.default_layer();
let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "x", ParaFormat::default()).unwrap();
assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), Some(a));
assert_eq!(d.hit_item(0, designcraft_geom::Point::new(300.0, 300.0), 2.0), None);
d.layer_mut(lid).unwrap().locked = true;
assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), None);
}
#[test]
fn page_names_follow_sections() {
let mut d = Document::new(&NewDocument { pages: 6, ..Default::default() });
assert_eq!(d.page_name(0), "1");
d.sections.push(Section { start: 2, start_number: Some(1), style: NumberStyle::LowerRoman, prefix: "A-".into(), marker: String::new(), include_prefix: true });
assert_eq!(d.page_name(1), "2");
assert_eq!(d.page_name(2), "A-i");
assert_eq!(d.page_name(4), "A-iii");
}
#[test]
fn document_serde_roundtrip() {
let mut d = doc();
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "héllo\nworld", ParaFormat::default()).unwrap();
let s = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
back.check().unwrap();
}

15
crates/fonts/Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "designcraft-fonts"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft fonts: font database (bundled OFL + system), metrics, glyph outlines, shaping"
[lints]
workspace = true
[dependencies]
kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
log = { workspace = true }

558
crates/fonts/src/fontdb.rs Normal file
View File

@ -0,0 +1,558 @@
//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
/// The family used when a requested family is unknown (the UI sans).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-Bold.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-It.ttf"),
include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"),
include_bytes!("../../../assets/fonts/SourceSerif4-It.ttf"),
include_bytes!("../../../assets/fonts/SourceSerif4-Semibold.ttf"),
include_bytes!("../../../assets/fonts/SourceSerif4-Bold.ttf"),
include_bytes!("../../../assets/fonts/SourceSerif4-BoldIt.ttf"),
include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
include_bytes!("../../../assets/fonts/Inter-Medium.ttf"),
include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"),
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
];
enum FontBytes {
Static(&'static [u8]),
Owned(Arc<Vec<u8>>),
}
/// One loaded font face.
pub struct FontFace {
id: u32,
/// Typographic family name (e.g. "Source Sans 3").
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
bytes: FontBytes,
index: u32,
pub upem: f64,
/// Ascender in font units (positive = up).
pub ascent: f64,
/// Descender in font units (positive = down).
pub descent: f64,
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
/// values).
pub cap_height: f64,
pub x_height: f64,
pub shaper: harfrust::ShaperData,
}
impl std::fmt::Debug for FontFace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FontFace({} {})", self.family, self.style)
}
}
impl FontFace {
pub fn data(&self) -> &[u8] {
match &self.bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
}
}
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
/// Unique id of this face within the process.
pub fn id(&self) -> u32 {
self.id
}
/// Does the face map `c` to a glyph?
pub fn covers(&self, c: char) -> bool {
self.skrifa().is_some_and(|f| f.charmap().map(c).is_some())
}
/// Units per em.
pub fn units_per_em(&self) -> f64 {
self.upem
}
/// (ascent, descent) in font units, both positive.
pub fn vertical_metrics(&self) -> (f64, f64) {
(self.ascent, self.descent)
}
/// Every mapped character and its glyph id, sorted by code point (the Glyphs panel).
pub fn chars(&self) -> Vec<(char, u32)> {
let Some(f) = self.skrifa() else { return vec![] };
let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect();
v.sort_unstable_by_key(|x| x.0);
v.dedup_by_key(|x| x.0);
v
}
/// Advance width of glyph `gid` in font units.
pub fn advance(&self, gid: u32) -> f64 {
self.skrifa()
.and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
.map(|a| a as f64)
.unwrap_or(self.upem * 0.5)
}
/// Glyph id for `c` (0 = .notdef).
pub fn glyph_for(&self, c: char) -> u32 {
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug)]
struct CatalogEntry {
family: String,
style: String,
path: std::path::PathBuf,
}
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// System fallback state: scanned yet, and characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct SysFallback {
enabled: bool,
scanned: bool,
misses: std::collections::HashSet<char>,
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
"Helvetica Neue",
"Arial",
"Segoe UI",
"Noto Sans",
"DejaVu Sans",
"PingFang SC",
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Apple SD Gothic Neo",
"Heiti SC",
"STHeiti",
"Microsoft YaHei",
"Yu Gothic",
"Malgun Gothic",
"Noto Sans CJK SC",
"Noto Sans CJK JP",
"Arial Unicode MS",
"Apple Symbols",
"Segoe UI Symbol",
"Noto Sans Symbols",
"Noto Sans Symbols2",
"Noto Emoji",
"Segoe UI Emoji",
"Apple Color Emoji",
"Noto Color Emoji",
];
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
}
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => 0,
};
(0..count)
.filter_map(|i| {
let f = skrifa::FontRef::from_index(data, i).ok()?;
let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
Some((i, family, style))
})
.collect()
}
fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(Size::unscaled(), LocationRef::default());
let a = f.attributes();
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
Some(FontFace {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
weight: a.weight.value(),
italic: !matches!(a.style, skrifa::attribute::Style::Normal),
upem: m.units_per_em.max(1) as f64,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
shaper,
bytes,
index,
})
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
}
/// Weight implied by a style name.
fn style_weight(style: &str) -> f32 {
let s = norm(style);
const TABLE: &[(&str, f32)] = &[
("extralight", 200.0),
("ultralight", 200.0),
("semibold", 600.0),
("demibold", 600.0),
("extrabold", 800.0),
("ultrabold", 800.0),
("hairline", 100.0),
("thin", 100.0),
("light", 300.0),
("medium", 500.0),
("bold", 700.0),
("black", 900.0),
("heavy", 900.0),
];
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
}
fn style_italic(style: &str) -> bool {
let s = norm(style);
s.contains("italic") || s.contains("oblique") || s == "it"
}
impl FontDb {
fn new_bundled() -> Self {
let mut faces = Vec::new();
for data in BUNDLED {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style) {
faces.push(Arc::new(f));
}
}
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
}
}
/// Enable or disable the lazy system-font fallback for characters the loaded fonts lack
/// (native only; on by default).
pub fn set_system_fallback(&self, on: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
}
#[cfg(target_arch = "wasm32")]
let _ = on;
}
/// Process-wide database preloaded with the bundled fonts.
pub fn global() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(FontDb::new_bundled)
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
}
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
pub fn families(&self) -> Vec<String> {
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
#[cfg(not(target_arch = "wasm32"))]
v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone()));
v.sort_by_key(|a| a.to_lowercase());
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
v
}
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
#[cfg(not(target_arch = "wasm32"))]
for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() {
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
}
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|t| t.2).collect()
}
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
/// every face was already present).
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
let data = Arc::new(bytes);
let mut added = 0;
for (i, family, style) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style) {
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
added += 1;
}
}
added
}
/// Scan the platform's font directories and catalog the faces found (native only; not called by
/// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the
/// number of faces cataloged.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts(&self) -> usize {
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
} else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
dirs.push(root.join("Fonts"));
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
}
let mut found = Vec::new();
let mut stack = dirs;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
for (_, family, style) in enumerate_faces(&data) {
found.push(CatalogEntry { family, style, path: p.clone() });
}
}
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
n
}
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let paths: Vec<std::path::PathBuf> = {
let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner());
let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect();
p.dedup();
p
};
let mut any = false;
for p in paths {
if let Ok(data) = std::fs::read(&p) {
any |= self.add_font(data) > 0;
}
}
any
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
/// Source Sans 3 Regular.
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
if let Some(f) = self.find(family, style) {
return f;
}
#[cfg(not(target_arch = "wasm32"))]
if self.load_cataloged(family)
&& let Some(f) = self.find(family, style)
{
return f;
}
self.find(FALLBACK_FAMILY, style)
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
.or_else(|| self.read_faces().first().cloned())
.expect("bundled fonts are always present")
}
/// Is `family` available (loaded)?
pub fn has_family(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
if cands.is_empty() {
return None;
}
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
.min_by(|a, b| {
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
sa.total_cmp(&sb)
})
.map(|f| (*f).clone())
}
/// First face (fallback family first, then load order) that covers `c`; on native, system
/// fonts are cataloged and loaded lazily the first time no loaded face covers a character.
pub fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
if let Some(f) = self.loaded_fallback(c, exclude) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_fallback(c) {
return self.loaded_fallback(c, exclude);
}
None
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
}
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback(&self, c: char) -> bool {
if c.is_control() || c.is_whitespace() {
return false;
}
{
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if !sys.enabled || sys.misses.contains(&c) {
return false;
}
if !sys.scanned {
sys.scanned = true;
if self.catalog.read().unwrap_or_else(|e| e.into_inner()).is_empty() {
drop(sys);
self.load_system_fonts();
}
}
}
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
let cataloged: Vec<String> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.family.clone()).collect();
for fam in SYSTEM_FALLBACKS {
if !self.has_family(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
if covered(self) {
return true;
}
}
}
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit =
enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font(data) > 0 && covered(self) {
return true;
}
}
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
false
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return p.clone();
}
let mut pen = FlipPen(BezPath::new());
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
struct FlipPen(BezPath);
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
fn close(&mut self) {
self.0.close_path();
}
}

139
crates/fonts/src/lib.rs Normal file
View File

@ -0,0 +1,139 @@
//! DesignCraft fonts: the font database (bundled OFL families + user/system fonts), vertical
//! metrics, glyph outlines and OpenType shaping.
//!
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
//! and advances in font units. `designcraft-compose` applies sizes, tracking, scaling and
//! justification on top.
#![forbid(unsafe_code)]
mod fontdb;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
/// InDesign's default text font is a serif; ours is Source Serif 4.
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
pub fn feature(tag: &str) -> Option<Feature> {
let (on, t) = match tag.strip_prefix('-') {
Some(r) => (false, r),
None => (true, tag.strip_prefix('+').unwrap_or(tag)),
};
let b = t.as_bytes();
if b.len() != 4 {
return None;
}
Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..))
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.set_direction(Direction::LeftToRight);
buf.guess_segment_properties();
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
}
out
}
/// Glyph id of the first of `chars` the face has (0 = .notdef).
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_families_load() {
let db = FontDb::global();
let fams = db.families();
assert!(fams.iter().any(|f| f == DEFAULT_FAMILY), "{fams:?}");
assert!(fams.iter().any(|f| f == "Source Sans 3"));
let styles = db.styles(DEFAULT_FAMILY);
for s in ["Regular", "Italic", "Bold", "Semibold"] {
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
}
}
#[test]
fn shaping_produces_clusters_and_advances() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let g = shape(&face, "Hello", &[], |c| c);
assert_eq!(g.len(), 5);
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![0, 1, 2, 3, 4]);
assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0));
}
#[test]
fn ligatures_can_be_disabled() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let on = shape(&face, "office", &[], |c| c);
let off = shape(&face, "office", &[feature("-liga").unwrap()], |c| c);
assert!(on.len() <= off.len());
assert_eq!(off.len(), 6);
}
#[test]
fn mapping_keeps_clusters() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase());
assert_eq!(g[0].gid, face.glyph_for('A'));
assert_eq!(g[1].cluster, 1);
}
#[test]
fn outlines_and_metrics() {
let db = FontDb::global();
let face = db.face(DEFAULT_FAMILY, "Bold");
assert_eq!(face.style, "Bold");
let o = db.outline(&face, face.glyph_for('O'));
assert!(!o.elements().is_empty());
assert!(face.ascent > 0.0 && face.descent > 0.0 && face.cap_height > face.x_height);
assert!(feature("abc").is_none());
}
}

23
crates/render/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "designcraft-render"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft renderer: spreads, pages, frames, composed text and images to pixels (vello_cpu)"
[lints]
workspace = true
[dependencies]
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
designcraft-fonts = { workspace = true }
designcraft-compose = { workspace = true }
vello_cpu = { workspace = true }
vello_common = { workspace = true }
image = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
vello_cpu = { workspace = true, features = ["multithreading"] }

674
crates/render/src/lib.rs Normal file
View File

@ -0,0 +1,674 @@
//! DesignCraft renderer: spreads → premultiplied RGBA pixels with `vello_cpu`.
//!
//! The renderer draws *document content only* (paper, page items, parent items, composed text,
//! placed images). Non-printing chrome — guides, frame edges, ports, selection — is drawn by the
//! UI as vector overlays so it stays crisp at any zoom.
//!
//! Callers pass a list of spreads with their placement on a shared canvas and a canvas → pixel
//! view transform, so the UI can render exactly its viewport with every visible spread.
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_color::BlendMode as DcBlend;
use designcraft_compose::{Cache, ComposedStory, FrameText};
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, SpreadRef, StrokeAlign, StrokeType};
use designcraft_fonts::FontDb;
use designcraft_geom::corners;
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
pub use vello_cpu;
/// A rendered image (premultiplied RGBA8, row-major).
#[derive(Clone)]
pub struct Rendered {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Rendered {
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.chunks_exact_mut(4) {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
pub fn to_png(&self) -> Vec<u8> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size");
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode");
buf
}
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.chunks_exact(4)
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8);
buf
}
/// Straight-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.width + x) * 4) as usize;
let p = &self.pixels[i..i + 4];
let a = p[3] as u32;
if a == 0 {
return [0, 0, 0, 0];
}
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
[un(p[0]), un(p[1]), un(p[2]), p[3]]
}
}
/// What to draw.
#[derive(Clone, Debug)]
pub struct RenderOptions {
/// Canvas background (premultiplied RGBA8); `None` = transparent.
pub background: Option<[u8; 4]>,
/// Fill pages with paper colour.
pub paper: bool,
/// Skip non-printing items and layers (Preview mode, export).
pub printing_only: bool,
/// Items not to draw (live drag previews).
pub hidden: Vec<designcraft_doc::ItemId>,
/// Draw text smaller than this many pixels as grey bars ("greeking"); 0 = never.
pub greek_below_px: f64,
/// Drop shadow on pages (screen view).
pub page_shadow: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self { background: None, paper: true, printing_only: false, hidden: vec![], greek_below_px: 0.0, page_shadow: false }
}
}
/// A spread placed on the canvas: canvas = spread + offset.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
pub spread: SpreadRef,
pub offset: Vec2,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameStats {
pub items: usize,
pub glyphs: usize,
pub micros: u64,
}
/// Reusable renderer (keeps the render context and decoded images).
pub struct Renderer {
ctx: Option<RenderContext>,
resources: Resources,
images: HashMap<(AssetId, usize), Arc<Pixmap>>,
pub threads: u16,
pub stats: FrameStats,
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
struct Frame<'a> {
doc: &'a Document,
cache: &'a Cache,
view: Affine,
visible: Rect,
px: f64,
opts: &'a RenderOptions,
}
pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")]
{
0
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(n) = std::env::var("DESIGNCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()) {
return n;
}
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).clamp(0, 8) as u16).unwrap_or(0)
}
}
impl Renderer {
pub fn new() -> Self {
Self { ctx: None, resources: Resources::new(), images: HashMap::new(), threads: default_threads(), stats: FrameStats::default() }
}
/// Render the given spreads into a `width`×`height` image with `view` (canvas → pixels).
pub fn render(&mut self, doc: &Document, cache: &Cache, spreads: &[Placed], width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let start = now();
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
let mut ctx = match self.ctx.take() {
Some(mut c) if c.width() == w && c.height() == h => {
c.reset();
c
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }),
};
self.stats = FrameStats::default();
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let canvas_visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
for pl in spreads {
let Some(sp) = doc.spread(pl.spread) else { continue };
let sview = view * Affine::translate(pl.offset);
let visible = canvas_visible - pl.offset;
let bounds = sp.bounds();
// Cull spreads far outside the viewport (pasteboard items may extend; allow a margin).
if !rect_overlaps(bounds.inflate(2000.0, 2000.0), visible) {
continue;
}
let f = Frame { doc, cache, view: sview, visible, px, opts };
self.draw_spread(&mut ctx, &f, pl.spread);
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
self.ctx = Some(ctx);
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
}
/// Render one document page (absolute index) at `scale` px/pt, optionally including bleed.
pub fn render_page(&mut self, doc: &Document, cache: &Cache, abs: usize, scale: f64, bleed: bool, opts: &RenderOptions) -> Option<Rendered> {
let (si, pi) = doc.page_loc(abs)?;
let page = &doc.spreads[si].pages[pi];
let mut r = page.bounds();
if bleed {
let b = doc.settings.bleed;
let (l, rr) = if page.side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) };
r = Rect::new(r.x0 - l, r.y0 - b[0], r.x1 + rr, r.y1 + b[1]);
}
let w = (r.width() * scale).round().max(1.0) as u32;
let h = (r.height() * scale).round().max(1.0) as u32;
let view = Affine::scale(scale) * Affine::translate(-r.origin().to_vec2());
let mut o = opts.clone();
o.background = Some([255, 255, 255, 255]);
Some(self.render(doc, cache, &[Placed { spread: SpreadRef::Doc(si), offset: Vec2::ZERO }], w, h, view, &o))
}
fn draw_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) {
let doc = f.doc;
let Some(sp) = doc.spread(sr) else { return };
// Paper.
if f.opts.paper {
let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE);
if f.opts.page_shadow {
ctx.set_transform(f.view);
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 90));
for p in &sp.pages {
let s = 3.0 * f.px;
ctx.fill_rect(&(p.bounds() + Vec2::new(s, s)));
}
}
ctx.set_transform(f.view);
ctx.set_paint(color_of(&paper, 1.0));
for p in &sp.pages {
ctx.fill_rect(&p.bounds());
}
}
// Layers back to front; within each layer parent items first (document spreads only).
for layer in doc.layers.iter().rev() {
if !layer.visible || f.opts.printing_only && !layer.printable {
continue;
}
if let SpreadRef::Doc(si) = sr {
let first = doc.first_page_of_spread(si);
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
let page_name = doc.page_name(first + pi);
let pr = parent.pages[ppage].bounds();
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
let _ = pr;
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name));
}
}
}
for it in &sp.items {
if it.layer == layer.id {
self.draw_item(ctx, f, it, Affine::IDENTITY, None);
}
}
}
}
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) {
if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting {
return;
}
let xf = parent * it.xf;
let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.weight + 2.0, it.stroke.weight + 2.0);
if !it.children().is_empty() {
// Groups: children carry their own transforms relative to the group.
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
for c in it.children() {
self.draw_item(ctx, f, c, xf, page_name);
}
if layered {
ctx.pop_layer();
}
return;
}
if !rect_overlaps(vb, f.visible) && !matches!(it.content, Content::Text(_)) {
return;
}
self.stats.items += 1;
let doc = f.doc;
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
}
// Drop shadow (simple offset silhouette).
if it.effects.drop_shadow.on {
let ds = &it.effects.drop_shadow;
let a = ds.angle.to_radians();
let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance);
if let Some(c) = doc.resolve_color(&ds.color, 1.0) {
ctx.set_transform(f.view * Affine::translate(off) * xf);
ctx.set_paint(color_of(&c, ds.opacity));
ctx.fill_path(&bp);
}
}
// Fill.
if !it.fill.is_none() {
ctx.set_transform(f.view * xf);
if set_fill_paint(ctx, doc, &it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle) && it.path.is_closed() {
ctx.fill_path(&bp);
}
ctx.reset_paint_transform();
}
// Content.
match &it.content {
Content::Graphic(g) => {
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(&bp);
self.draw_graphic(ctx, f, g, xf);
ctx.pop_layer();
}
Content::Text(tf) => {
let cs = f.cache.get(doc, tf.story, page_name);
if let Some(ft) = cs.frame(it.id) {
self.draw_text(ctx, f, &cs, ft, xf);
}
}
_ => {}
}
// Stroke.
if !it.stroke.is_none() {
self.draw_stroke(ctx, f, it, &bp, xf);
}
if layered {
ctx.pop_layer();
}
}
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) {
let st = &it.stroke;
let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return };
let mut stroke = kurbo::Stroke::new(st.weight)
.with_caps(match st.cap {
designcraft_doc::Cap::Butt => kurbo::Cap::Butt,
designcraft_doc::Cap::Round => kurbo::Cap::Round,
designcraft_doc::Cap::Projecting => kurbo::Cap::Square,
})
.with_join(match st.join {
designcraft_doc::Join::Miter => kurbo::Join::Miter,
designcraft_doc::Join::Round => kurbo::Join::Round,
designcraft_doc::Join::Bevel => kurbo::Join::Bevel,
})
.with_miter_limit(st.miter_limit);
match &st.kind {
StrokeType::Dashed { pattern } if !pattern.is_empty() => stroke = stroke.with_dashes(0.0, pattern.iter().copied()),
StrokeType::Dotted => stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round),
_ => {}
}
ctx.set_transform(f.view * xf);
ctx.set_paint(color_of(&c, 1.0));
let closed = it.path.is_closed();
match st.align {
StrokeAlign::Inside if closed => {
// Clip a double-width stroke to the path.
ctx.push_clip_layer(bp);
ctx.set_stroke(kurbo::Stroke { width: st.weight * 2.0, ..stroke });
ctx.stroke_path(bp);
ctx.pop_layer();
}
StrokeAlign::Outside if closed => {
let outline = kurbo::stroke(bp.iter(), &kurbo::Stroke { width: st.weight * 2.0, ..stroke }, &kurbo::StrokeOpts::default(), 0.05 * f.px);
let mut p = outline;
// Remove the interior: even-odd with the path itself.
for el in bp.elements() {
p.push(*el);
}
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&p);
ctx.set_fill_rule(peniko::Fill::NonZero);
}
_ => {
ctx.set_stroke(stroke);
ctx.stroke_path(bp);
}
}
}
fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) {
let Some(asset) = f.doc.assets.get(&g.asset) else { return };
// Pick a mip level close to the on-screen size.
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
let full = match self.images.get(&(g.asset, 0)) {
Some(p) => p.clone(),
None => {
let Some(pm) = decode_pixmap(&asset.data) else { return };
let pm = Arc::new(pm);
self.images.insert((g.asset, 0), pm.clone());
pm
}
};
let ppt = full.width() as f64 / g.size.0.max(1e-6); // pixels per point
let need = on_screen * 1.0; // pixels per point on screen
let mut level = 0usize;
let mut pm = full.clone();
while ppt / (1 << (level + 1)) as f64 >= need * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 {
level += 1;
pm = match self.images.get(&(g.asset, level)) {
Some(p) => p.clone(),
None => {
let half = Arc::new(halve(&pm));
self.images.insert((g.asset, level), half.clone());
half
}
};
}
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1);
ctx.set_transform(f.view * xf * g.xf);
let sx = g.size.0 / pm.width().max(1) as f64;
let sy = g.size.1 / pm.height().max(1) as f64;
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium) });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &ComposedStory, ft: &FrameText, xf: Affine) {
let db = FontDb::global();
let doc = f.doc;
// Decorations under text (shading) and rules.
for d in &ft.decos {
if let Some(c) = doc.resolve_color(&d.color, d.tint) {
ctx.set_transform(f.view * xf);
ctx.set_paint(color_of(&c, 1.0));
ctx.fill_rect(&d.rect);
}
}
let scale = (f.view * xf).determinant().abs().sqrt();
// Batch glyph outlines per run style into one path (in frame inner space).
let mut batches: HashMap<u32, BezPath> = HashMap::new();
let mut lines_deco: Vec<(u32, kurbo::Rect)> = Vec::new();
for l in &ft.lines {
let greek = f.opts.greek_below_px > 0.0 && l.ascent * scale < f.opts.greek_below_px;
if greek {
if let (Some(a), Some(_)) = (l.glyphs.first(), l.glyphs.last()) {
let r = kurbo::Rect::new(a.x, l.baseline - l.ascent * 0.5, l.end_x, l.baseline);
lines_deco.push((u32::MAX, r));
}
continue;
}
for g in &l.glyphs {
if !g.visible {
continue;
}
let style = &cs.styles[g.style as usize];
if style.underline || style.strikethrough {
let w = style.size / 14.0;
if style.underline {
lines_deco.push((g.style, kurbo::Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w)));
}
if style.strikethrough {
lines_deco.push((g.style, kurbo::Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w)));
}
}
let outline = db.outline(&g.face, g.gid);
if outline.elements().is_empty() {
continue;
}
let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
let b = batches.entry(g.style).or_default();
for el in outline.elements() {
b.push(transform_el(a, *el));
}
self.stats.glyphs += 1;
}
}
ctx.set_transform(f.view * xf);
for (si, bp) in &batches {
let st = &cs.styles[*si as usize];
if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) {
ctx.set_paint(color_of(&c, 1.0));
ctx.fill_path(bp);
}
if st.stroke != designcraft_color::swatch::NONE
&& let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint)
{
ctx.set_paint(color_of(&c, 1.0));
ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight));
ctx.stroke_path(bp);
}
}
for (si, r) in lines_deco {
let c = if si == u32::MAX {
designcraft_color::Color::gray(0.35)
} else {
let st = &cs.styles[si as usize];
doc.resolve_color(&st.fill, st.fill_tint).unwrap_or(designcraft_color::Color::BLACK)
};
ctx.set_paint(color_of(&c, if si == u32::MAX { 0.5 } else { 1.0 }));
ctx.fill_rect(&r);
}
}
}
fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl {
use kurbo::PathEl::*;
match el {
MoveTo(p) => MoveTo(a * p),
LineTo(p) => LineTo(a * p),
QuadTo(p1, p2) => QuadTo(a * p1, a * p2),
CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3),
ClosePath => ClosePath,
}
}
fn rect_overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0
}
pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
let [r, g, b, a] = c.to_rgba8(alpha);
peniko::Color::from_rgba8(r, g, b, a)
}
/// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown.
fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, swatch: &str, tint: f32, bounds: Rect, angle: Option<f64>) -> bool {
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) {
let stops: Vec<peniko::ColorStop> = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
if stops.is_empty() {
return false;
}
let c = bounds.center();
let grad = match g.kind {
designcraft_color::GradientKind::Radial => {
let r = bounds.width().max(bounds.height()) / 2.0;
peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice())
}
_ => {
let a = angle.unwrap_or(0.0).to_radians();
let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0;
let d = Vec2::new(a.cos(), -a.sin()) * half;
peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
return true;
}
match doc.resolve_color(swatch, tint) {
Some(c) => {
ctx.set_paint(color_of(&c, 1.0));
true
}
None => false,
}
}
pub fn blend_mode(b: DcBlend) -> BlendMode {
use DcBlend as B;
let mix = match b {
B::Normal => Mix::Normal,
B::Darken => Mix::Darken,
B::Multiply => Mix::Multiply,
B::ColorBurn => Mix::ColorBurn,
B::Lighten => Mix::Lighten,
B::Screen => Mix::Screen,
B::ColorDodge => Mix::ColorDodge,
B::Overlay => Mix::Overlay,
B::SoftLight => Mix::SoftLight,
B::HardLight => Mix::HardLight,
B::Difference => Mix::Difference,
B::Exclusion => Mix::Exclusion,
B::Hue => Mix::Hue,
B::Saturation => Mix::Saturation,
B::Color => Mix::Color,
B::Luminosity => Mix::Luminosity,
};
BlendMode::new(mix, Compose::SrcOver)
}
/// Decode encoded image bytes into a premultiplied pixmap.
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Pixmap::from_parts(data, w as u16, h as u16))
}
/// Box-filter downsample by 2.
fn halve(pm: &Pixmap) -> Pixmap {
let (w, h) = (pm.width() as usize, pm.height() as usize);
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
let src = pm.data();
let mut out = Vec::with_capacity(nw * nh);
for y in 0..nh {
for x in 0..nw {
let mut acc = [0u32; 4];
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let sx = (2 * x + dx).min(w - 1);
let sy = (2 * y + dy).min(h - 1);
let p = src[sy * w + sx];
acc[0] += p.r as u32;
acc[1] += p.g as u32;
acc[2] += p.b as u32;
acc[3] += p.a as u32;
}
out.push(vello_cpu::color::PremulRgba8 { r: (acc[0] / 4) as u8, g: (acc[1] / 4) as u8, b: (acc[2] / 4) as u8, a: (acc[3] / 4) as u8 });
}
}
Pixmap::from_parts(out, nw as u16, nh as u16)
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
fn now() -> u64 {
0
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Fill, ParaFormat};
#[test]
fn renders_page_with_text_and_fill() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, "Hello DesignCraft", ParaFormat::default()).unwrap();
let id = designcraft_doc::ItemId(d.alloc());
let mut box_ = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 136.0, 500.0)));
box_.fill = Fill::swatch("C=100 M=0 Y=0 K=0");
d.insert_item(SpreadRef::Doc(0), box_, None).unwrap();
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
assert_eq!((img.width, img.height), (612, 792));
// Paper is white, the box is cyan-ish, text drew some dark pixels.
assert_eq!(img.pixel(300, 700), [255, 255, 255, 255]);
let c = img.pixel(80, 450);
assert!(c[0] < 80 && c[2] > 150, "{c:?}");
let dark = (36..300).flat_map(|x| (36..60).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count();
assert!(dark > 50, "text pixels: {dark}");
let _ = fid;
assert!(r.stats.glyphs >= 15);
}
}

17
crates/tools/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "designcraft-tools"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft tools: pointer events in, commands and overlays out (Selection, Direct Selection, frames, Type, Hand, Zoom…)"
[lints]
workspace = true
[dependencies]
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
designcraft-compose = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -0,0 +1,71 @@
//! The tool catalogue: ids, labels, shortcuts and Tools-panel groups (InDesign's order).
use serde::Serialize;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct ToolInfo {
pub id: &'static str,
pub label: &'static str,
pub shortcut: Option<&'static str>,
pub icon: &'static str,
}
const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str>, icon: &'static str) -> ToolInfo {
ToolInfo { id, label, shortcut, icon }
}
/// Tools-panel groups in order (the first tool of each group shows by default). A `None` group
/// marks a separator.
pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("selection", "Selection Tool", Some("V"), "tool-selection")],
&[t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct")],
&[t("page", "Page Tool", Some("Shift+P"), "tool-page")],
&[t("gap", "Gap Tool", Some("U"), "tool-gap")],
&[t("contentCollector", "Content Collector Tool", Some("B"), "tool-collector"), t("contentPlacer", "Content Placer Tool", Some("B"), "tool-placer")],
&[],
&[
t("type", "Type Tool", Some("T"), "tool-type"),
t("typeOnPath", "Type on a Path Tool", Some("Shift+T"), "tool-type-path"),
t("verticalType", "Vertical Type Tool", None, "tool-type-vertical"),
t("verticalTypeOnPath", "Vertical Type on a Path Tool", None, "tool-type-path"),
],
&[t("line", "Line Tool", Some("\\"), "tool-line")],
&[
t("pen", "Pen Tool", Some("P"), "tool-pen"),
t("addAnchor", "Add Anchor Point Tool", Some("="), "tool-pen-add"),
t("deleteAnchor", "Delete Anchor Point Tool", Some("-"), "tool-pen-delete"),
t("convertDirection", "Convert Direction Point Tool", Some("Shift+C"), "tool-anchor"),
],
&[t("pencil", "Pencil Tool", Some("N"), "tool-pencil"), t("smooth", "Smooth Tool", None, "tool-smooth"), t("erase", "Erase Tool", None, "tool-erase")],
&[
t("rectangleFrame", "Rectangle Frame Tool", Some("F"), "tool-rect-frame"),
t("ellipseFrame", "Ellipse Frame Tool", None, "tool-ellipse-frame"),
t("polygonFrame", "Polygon Frame Tool", None, "tool-polygon-frame"),
],
&[t("rectangle", "Rectangle Tool", Some("M"), "tool-rect"), t("ellipse", "Ellipse Tool", Some("L"), "tool-ellipse"), t("polygon", "Polygon Tool", None, "tool-polygon")],
&[t("scissors", "Scissors Tool", Some("C"), "tool-scissors")],
&[
t("freeTransform", "Free Transform Tool", Some("E"), "tool-free-transform"),
t("rotate", "Rotate Tool", Some("R"), "tool-rotate"),
t("scale", "Scale Tool", Some("S"), "tool-scale"),
t("shear", "Shear Tool", Some("O"), "tool-shear"),
],
&[t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"), t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather")],
&[t("note", "Note Tool", None, "tool-note")],
&[
t("colorTheme", "Color Theme Tool", Some("Shift+I"), "tool-color-theme"),
t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"),
t("measure", "Measure Tool", Some("K"), "tool-measure"),
],
&[t("hand", "Hand Tool", Some("H"), "tool-hand")],
&[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")],
];
pub fn tool_info(id: &str) -> Option<ToolInfo> {
TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied()
}
/// Tool for a single-key shortcut (`"T"`, `"Shift+P"`).
pub fn tool_for_shortcut(key: &str) -> Option<&'static str> {
TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut == Some(key)).map(|t| t.id)
}

101
crates/tools/src/layout.rs Normal file
View File

@ -0,0 +1,101 @@
//! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0,
//! parents shown on their own canvas when a parent spread is being edited.
use designcraft_doc::{Document, SpreadRef};
use designcraft_geom::{Point, Rect, Vec2};
use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct SpreadSlot {
pub spread: SpreadRef,
/// canvas = spread + offset
pub offset: Vec2,
/// Spread page bounds in canvas coordinates.
pub bounds: Rect,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct CanvasLayout {
pub slots: Vec<SpreadSlot>,
/// Pasteboard extent (canvas coordinates).
pub pasteboard: Rect,
}
/// Vertical gap between spreads (points).
pub const SPREAD_GAP: f64 = 72.0;
impl CanvasLayout {
/// Layout of the document spreads (or the parent spreads when `parents` is true).
pub fn new(doc: &Document, parents: bool) -> Self {
let list: Vec<SpreadRef> = if parents { (0..doc.parents.len()).map(SpreadRef::Parent).collect() } else { (0..doc.spreads.len()).map(SpreadRef::Doc).collect() };
let mut slots = Vec::with_capacity(list.len());
let mut y = 0.0;
let mut pb: Option<Rect> = None;
let (px, py) = doc.settings.pasteboard;
for r in list {
let Some(sp) = doc.spread(r) else { continue };
let b = sp.bounds();
let offset = Vec2::new(-sp.spine_x(), y - b.y0);
let cb = b + offset;
let pr = Rect::new(cb.x0 - px.max(b.width()), cb.y0 - py * 0.5, cb.x1 + px.max(b.width()), cb.y1 + py * 0.5);
pb = Some(pb.map_or(pr, |p| p.union(pr)));
slots.push(SpreadSlot { spread: r, offset, bounds: cb });
y += b.height() + SPREAD_GAP;
}
let pasteboard = pb.unwrap_or(Rect::new(-500.0, -500.0, 500.0, 500.0)).inflate(0.0, py * 0.5);
CanvasLayout { slots, pasteboard }
}
/// The spread under (or nearest to) a canvas point, and the point in spread coordinates.
pub fn spread_at(&self, p: Point) -> Option<(SpreadRef, Point)> {
let s = self.slots.iter().min_by(|a, b| dist_y(a.bounds, p.y).total_cmp(&dist_y(b.bounds, p.y)))?;
Some((s.spread, p - s.offset))
}
pub fn slot(&self, r: SpreadRef) -> Option<&SpreadSlot> {
self.slots.iter().find(|s| s.spread == r)
}
pub fn offset(&self, r: SpreadRef) -> Vec2 {
self.slot(r).map(|s| s.offset).unwrap_or(Vec2::ZERO)
}
/// Canvas rect of a document page.
pub fn page_rect(&self, doc: &Document, abs: usize) -> Option<Rect> {
let (si, pi) = doc.page_loc(abs)?;
let off = self.offset(SpreadRef::Doc(si));
Some(doc.spreads[si].pages[pi].bounds() + off)
}
}
fn dist_y(r: Rect, y: f64) -> f64 {
if y < r.y0 - SPREAD_GAP / 2.0 {
r.y0 - y
} else if y > r.y1 + SPREAD_GAP / 2.0 {
y - r.y1
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
#[test]
fn spreads_stack_on_the_spine() {
let d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let l = CanvasLayout::new(&d, false);
assert_eq!(l.slots.len(), 2);
// Page 1 is a right page: it starts at the spine (x = 0).
assert_eq!(l.slots[0].bounds.x0, 0.0);
// Spread 2 (pages 2–3) straddles the spine.
assert_eq!(l.slots[1].bounds.x0, -612.0);
assert_eq!(l.slots[1].bounds.y0, 792.0 + SPREAD_GAP);
let (r, p) = l.spread_at(Point::new(-100.0, 900.0)).unwrap();
assert_eq!(r, SpreadRef::Doc(1));
assert_eq!(p, Point::new(512.0, 900.0 - 792.0 - SPREAD_GAP));
assert_eq!(l.page_rect(&d, 2).unwrap().x0, 0.0);
}
}

249
crates/tools/src/lib.rs Normal file
View File

@ -0,0 +1,249 @@
//! DesignCraft tools: pointer events in → commands and overlays out.
//!
//! Tools never mutate the document. They emit [`Action`]s the engine executes: `Begin` snapshots
//! the document, each `Preview` re-applies one command on top of the snapshot, `Commit` records
//! the last preview as one undo step. `Exec` runs a command immediately. Every gesture is therefore
//! replayable by the control channel and MCP, and tools are testable without a UI.
//!
//! Pointer positions are **canvas** coordinates (see [`layout::CanvasLayout`]).
#![forbid(unsafe_code)]
pub mod catalog;
mod frame;
pub mod layout;
mod nav;
mod select;
mod text;
use designcraft_compose::Cache;
use designcraft_doc::{Document, Item, ItemId, Selection, SpreadRef};
use designcraft_geom::{Affine, BezPath, Point, Rect};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info};
pub use layout::CanvasLayout;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mods {
#[serde(default)]
pub shift: bool,
#[serde(default)]
pub alt: bool,
/// Command on macOS, Ctrl elsewhere.
#[serde(default)]
pub cmd: bool,
#[serde(default)]
pub ctrl: bool,
#[serde(default)]
pub space: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PointerKind {
Down,
Drag,
Up,
Move,
DoubleClick,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PointerEvent {
pub kind: PointerKind,
/// Canvas coordinates.
pub pos: Point,
#[serde(default)]
pub mods: Mods,
}
impl PointerEvent {
pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {
Self { kind, pos: Point::new(x, y), mods: Mods::default() }
}
pub fn with_mods(mut self, m: Mods) -> Self {
self.mods = m;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolKey {
Enter,
Escape,
Backspace,
Delete,
Up,
Down,
Left,
Right,
Home,
End,
Tab,
PageUp,
PageDown,
}
/// What a tool asks the engine to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
Begin(String),
Preview(String, Value),
Commit,
Cancel,
Exec(String, Value),
/// Ask the UI to open a dialog.
Dialog(String, Value),
SwitchTool(String),
/// Ask the UI to pan/zoom: `{"pan":[dx,dy]}` (screen px) or `{"zoomAt":[x,y], "factor":f}` (canvas).
View(Value),
}
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
pub selection: &'a Selection,
pub cache: &'a Cache,
pub layout: &'a CanvasLayout,
/// Screen pixels per point.
pub zoom: f64,
/// Layer new items go on.
pub layer: designcraft_doc::LayerId,
pub snap: bool,
}
impl ToolContext<'_> {
pub fn tol(&self, px: f64) -> f64 {
px / self.zoom.max(1e-9)
}
/// Frontmost unlocked item under a canvas point.
pub fn hit(&self, p: Point) -> Option<(SpreadRef, ItemId)> {
let (sr, sp) = self.layout.spread_at(p)?;
let SpreadRef::Doc(si) = sr else {
return self.hit_parent(sr, sp);
};
self.doc.hit_item(si, sp, self.tol(4.0)).map(|id| (sr, id))
}
fn hit_parent(&self, sr: SpreadRef, sp: Point) -> Option<(SpreadRef, ItemId)> {
let s = self.doc.spread(sr)?;
s.items.iter().rev().find(|it| !it.locked && designcraft_doc::edit_hit(it, sp, self.tol(4.0))).map(|it| (sr, it.id))
}
/// Spread-space → canvas transform of the spread holding item `id`.
pub fn item_canvas_xf(&self, id: ItemId) -> Option<Affine> {
let loc = self.doc.find(id)?;
let off = self.layout.offset(loc.spread);
Some(Affine::translate(off) * self.doc.parent_xf(&loc))
}
pub fn item(&self, id: ItemId) -> Option<&Item> {
self.doc.item(id)
}
/// Canvas bounds of the selected items.
pub fn selection_bounds(&self) -> Option<Rect> {
let mut r: Option<Rect> = None;
for id in &self.selection.items {
let (Some(it), Some(xf)) = (self.doc.item(*id), self.item_canvas_xf(*id)) else { continue };
let b = xf.transform_rect_bbox(it.bounds());
r = Some(r.map_or(b, |r| r.union(b)));
}
r
}
}
/// Visual feedback (canvas coordinates) drawn by the UI.
#[derive(Clone, Debug, PartialEq)]
pub enum Overlay {
Marquee(Rect),
/// Preview outline of a frame being drawn.
Path { path: BezPath, color: [u8; 3], dashed: bool },
Line { a: Point, b: Point, color: [u8; 3], dashed: bool },
/// Measurement pill near the cursor.
Measure { p: Point, text: String },
/// Smart guide line in the smart-guide colour.
Guide { a: Point, b: Point },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Cursor {
#[default]
Arrow,
ArrowHollow,
Move,
Crosshair,
ResizeH,
ResizeV,
ResizeNwSe,
ResizeNeSw,
Rotate,
Pen,
Text,
Hand,
HandGrab,
ZoomIn,
ZoomOut,
Eyedropper,
LoadedText,
LoadedGraphic,
NotAllowed,
}
pub trait Tool: Send {
fn id(&self) -> &'static str;
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action>;
fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec<Action> {
vec![]
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
vec![]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Arrow
}
fn busy(&self) -> bool {
false
}
/// The tool takes typed text (Type tool with a caret): single-key shortcuts are suppressed.
fn wants_text(&self, _cx: &ToolContext) -> bool {
false
}
}
pub fn create(id: &str) -> Box<dyn Tool> {
match id {
"selection" => Box::new(select::SelectionTool::new(false)),
"directSelection" => Box::new(select::SelectionTool::new(true)),
"rectangleFrame" | "ellipseFrame" | "polygonFrame" | "rectangle" | "ellipse" | "polygon" | "line" => Box::new(frame::FrameTool::new(id)),
"type" => Box::new(text::TypeTool::default()),
"hand" => Box::new(nav::HandTool::default()),
"zoom" => Box::new(nav::ZoomTool::default()),
other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))),
}
}
/// Placeholder for tools whose behaviour hasn't landed yet.
pub struct NoopTool(&'static str);
impl Tool for NoopTool {
fn id(&self) -> &'static str {
self.0
}
fn pointer(&mut self, _cx: &ToolContext, _ev: &PointerEvent) -> Vec<Action> {
vec![]
}
}
pub(crate) fn rect_json(r: Rect) -> Value {
serde_json::json!([r.x0, r.y0, r.x1, r.y1])
}
pub(crate) fn spread_json(r: SpreadRef) -> Value {
serde_json::to_value(r).unwrap_or(Value::Null)
}
#[cfg(test)]
mod tests;