continued work
This commit is contained in:
parent
e1e1352fff
commit
c9dcad1f6e
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue