M7: footnotes — Insert Footnote, Document Footnote Options, column-bottom layout, PDF/IDML; generic command dialogs

- doc: FOOTNOTE_REF marker + Story::notes (kept in sync on insert/delete/thread), FootnoteOptions,
  NumberStyle::Symbols, footnote text addressed like cell text (CellAddr::footnote)
- compose: notes numbered (continuous / restart per page, spread, section), composed at column
  width, body text makes room, stacked at the column bottom with the rule above
- render + PDF draw footnote text; IDML export/import in InDesign's structure (verified in
  InDesign 2026), FootnoteOption preferences
- engine: footnote.insert/options/list/setText/delete/goToReference (+ tests)
- UI: caret/selection in footnote text, Footnote Options dialog, Type menu entries; menu items
  labelled '…' with parameters open a dialog generated from the command's params doc
- sample magazine carries a footnote

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 21:01:27 -04:00
co-authored by Claude Opus 5.5
parent 7bf8167638
commit 3ad71d5a76
30 changed files with 1661 additions and 23 deletions
+3 -2
View File
@@ -17,13 +17,14 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
- Data Merge (CSV or JSON rows, `<<Field>>` placeholders), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options.
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** footnotes, cross-references, index · table/cell styles, text rotation in cells · PDF/X-4 output intent, tagged PDF · Links panel + relink · UI for Data Merge/spelling/hyperlinks.
**Next (in order):** cross-references, index, footnotes in EPUB / span columns / split footnotes · table/cell styles, text rotation in cells · PDF/X-4 output intent, tagged PDF · Links panel + relink · UI for Data Merge/spelling/hyperlinks.
## Milestones
@@ -36,7 +37,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
| M4 | Color & effects | started |
| M5 | Graphics & links | started |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes) |
| M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) |
| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, culling, parallel compose 6.7× faster, perf harness) |
| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) |
+1 -2
View File
@@ -75,8 +75,7 @@ impl NativeMenu {
pub fn poll(&mut self, app: &mut DesignApp) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
let p = if params.is_null() { serde_json::json!({}) } else { params.clone() };
let _ = app.run(cmd, p);
designcraft_ui_egui::menus::activate(app, &cmd.clone(), &params.clone());
}
}
let now = designcraft_ui_egui::now_ms();
+8 -1
View File
@@ -68,6 +68,13 @@ impl Cache {
sig.push(doc_signature(doc) as usize);
}
}
if !story.notes.is_empty() {
// Footnote numbers continue from earlier stories; layout follows the options.
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{:?}", doc.footnote_options).hash(&mut h);
sig.extend([doc.footnote_start(sid) as usize, h.finish() as usize]);
}
{
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
g.1 += 1;
@@ -85,7 +92,7 @@ impl Cache {
_ => None,
};
let running = running.then(|| self.running_index(doc));
let out = Arc::new(crate::compose_story(doc, sid, &ComposeOptions { page_name: page_name.map(str::to_string), page, running }));
let out = Arc::new(crate::compose_story(doc, sid, &ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None }));
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let stamp = g.1;
if g.0.len() > 4096 {
+53 -3
View File
@@ -12,13 +12,14 @@
pub mod breaker;
mod cache;
pub mod hyphen;
mod notes;
pub mod shape;
pub mod table;
pub mod vars;
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use designcraft_doc::{
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
@@ -29,6 +30,8 @@ use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, note_caret};
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell};
@@ -108,6 +111,26 @@ pub struct FrameText {
pub content_height: f64,
/// Table fragments placed in this frame (each also has a glyph-less line in `lines`).
pub tables: Vec<TableFrag>,
/// Footnotes placed at the bottom of this frame's columns.
pub notes: Vec<PlacedNote>,
}
/// A footnote composed at the bottom of a column.
#[derive(Clone, Debug)]
pub struct PlacedNote {
/// Footnote id (in the story's [`Story::notes`]) and its index there.
pub id: u64,
pub index: usize,
pub column: u32,
/// The number shown.
pub label: String,
/// Where the footnote's composed text (its frame 0) sits in the frame.
pub origin: Point,
/// Area of the footnote in frame inner space.
pub rect: Rect,
pub text: Arc<ComposedStory>,
/// The footnote's story text (exporters map glyphs back to text).
pub source: Arc<str>,
}
#[derive(Clone, Debug, Default)]
@@ -183,6 +206,8 @@ pub struct ComposeOptions {
pub page: Option<usize>,
/// Running-header index (needed when the story contains running-header variables).
pub running: Option<std::sync::Arc<vars::RunningIndex>>,
/// Generated text before the first paragraph (a footnote's number and separator).
pub label: Option<String>,
}
/// Build the frame specs of a story from the document (geometry, wrap, page names, grid).
@@ -269,12 +294,16 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
let mut notes = Notes::new(doc, story);
let mut note_snaps: Vec<(u32, Option<usize>, usize)> = Vec::with_capacity(np);
let mut var_cache: std::collections::HashMap<Option<usize>, std::sync::Arc<Vec<String>>> = Default::default();
'paras: while pi < np {
let prange = para_ranges[pi].clone();
let snap = Snapshot::take(&out, &cur, list_counter);
snaps.truncate(pi);
snaps.push(snap);
note_snaps.truncate(pi);
note_snaps.push((notes.num, notes.key, notes.placed.len()));
info.truncate(pi);
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
@@ -320,11 +349,15 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
} else {
Default::default()
};
let sub = SubstCtx {
let mut sub = SubstCtx {
page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())),
section_marker: None,
vars: var_values,
..Default::default()
};
if notes.active() {
notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub);
}
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 {
@@ -339,6 +372,11 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
}
designcraft_doc::ListType::None => list_counter = 0,
}
if pi == 0
&& let Some(label) = &opts.label
{
prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, &pp, &mut table);
}
let mut glyphs = sp.glyphs;
apply_desired_spacing(&mut glyphs, &pp);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp);
@@ -438,7 +476,13 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
}
}
let capped = line_cap[pi] == Some(line_no) && line_no > col_first_line;
let fits = baseline + desc <= col.y1 + 0.01 && !capped;
// Footnotes referenced on this line need room at the bottom of the column too
// (a line at the top of a column is set anyway).
let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() };
let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width();
let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts);
let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve };
let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped;
if !fits {
if !capped {
let ctx = KeepCtx {
@@ -468,6 +512,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
line_cap[k] = None;
}
snaps[j].restore(&mut out, &mut cur, &mut list_counter);
(notes.num, notes.key) = (note_snaps[j].0, note_snaps[j].1);
notes.placed.truncate(note_snaps[j].2);
pi = j;
continue 'paras;
}
@@ -509,6 +555,9 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
end_x,
spacing: ratio,
});
for k in line_notes {
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
}
cur.last_baseline = Some(baseline);
cur.last_descent = desc;
cur.pending = 0.0;
@@ -593,6 +642,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
cur.pending += pp.space_after;
pi += 1;
}
notes.finish(doc, story, frames, &cols, &mut out);
// Ranges, content heights and vertical justification.
for (fi, ft) in out.frames.iter_mut().enumerate() {
let f = &frames[fi];
+206
View File
@@ -0,0 +1,206 @@
//! Footnote layout: numbering, room at the bottom of columns, and placement.
//!
//! When a line holding footnote references is set, each referenced footnote is composed at the
//! column width and the column's text area shrinks by its height (plus the minimum space before
//! the first footnote and the space between footnotes). A line that would collide moves to the
//! next column with its footnotes. After the story is laid out the footnotes of each column are
//! stacked against the column bottom, with the separator rule above the first one.
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use designcraft_doc::notes::NoteRestart;
use designcraft_doc::{Document, FOOTNOTE_REF, ItemId, Story, TextFrameOptions};
use designcraft_geom::{Point, Rect};
use crate::shape::{Glyph, SubstCtx};
use crate::{ComposeOptions, ComposedStory, Deco, FrameSpec, PlacedNote};
/// A footnote assigned to a column.
pub(crate) struct Placed {
k: usize,
fi: usize,
col: usize,
h: f64,
text: Arc<ComposedStory>,
}
pub(crate) struct Notes {
/// Byte offsets of the reference characters (index = footnote index).
refs: Vec<usize>,
/// Number of each footnote (assigned when its paragraph is shaped).
numbers: Vec<u32>,
/// Next number and the restart unit (page / spread / section start) it counts in.
pub num: u32,
pub key: Option<usize>,
pub placed: Vec<Placed>,
/// Composed footnote text by (index, label, width).
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, f64)>,
notes: Vec<Arc<designcraft_doc::Footnote>>,
}
impl Notes {
pub fn new(doc: &Document, story: &Story) -> Notes {
let refs: Vec<usize> = if story.notes.is_empty() { Vec::new() } else { story.text.match_indices(FOOTNOTE_REF).map(|(i, _)| i).collect() };
let start = if refs.is_empty() { 1 } else { doc.footnote_start(story.id) };
Notes { numbers: vec![start; refs.len()], refs, num: start, key: None, placed: Vec::new(), cache: HashMap::new(), notes: story.notes.clone() }
}
pub fn active(&self) -> bool {
!self.refs.is_empty() && self.refs.len() == self.notes.len()
}
/// Number the references in a paragraph (shaped on `page`) and hand their labels to the shaper.
pub fn number_refs(&mut self, doc: &Document, prange: Range<usize>, page: Option<usize>, sub: &mut SubstCtx) {
let o = &doc.footnote_options;
let key = page.and_then(|p| match o.restart {
NoteRestart::Never => None,
NoteRestart::Page => Some(p),
NoteRestart::Spread => doc.page_loc(p).map(|l| l.0),
NoteRestart::Section => Some(doc.section_of(p).map_or(0, |s| s.start)),
});
let a = self.refs.partition_point(|&b| b < prange.start);
let b = self.refs.partition_point(|&b| b < prange.end);
for k in a..b {
if o.restart != NoteRestart::Never && key != self.key {
self.key = key;
self.num = o.start_at;
}
self.numbers[k] = self.num;
self.num += 1;
sub.notes.insert(self.refs[k], o.label(self.numbers[k], false));
}
sub.note_position = o.ref_position;
sub.note_style = (o.ref_char_style != designcraft_doc::NO_CHAR_STYLE).then(|| o.ref_char_style.clone());
}
/// Footnote indices referenced by a line's glyphs.
pub fn refs_in(&self, glyphs: &[Glyph]) -> Vec<usize> {
let mut v: Vec<usize> = Vec::new();
for g in glyphs.iter().filter(|g| g.ch == FOOTNOTE_REF && g.len > 0) {
if let Ok(k) = self.refs.binary_search(&g.byte)
&& !v.contains(&k)
{
v.push(k);
}
}
v
}
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, f64) {
let o = &doc.footnote_options;
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
let key = (k, label.clone(), w.to_bits());
if let Some(c) = self.cache.get(&key) {
return c.clone();
}
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
opts: TextFrameOptions { first_baseline: o.first_baseline, first_baseline_min: o.first_baseline_min, ..Default::default() },
exclusions: vec![],
page_name: f.page_name.clone(),
page: f.page,
grid: None,
left_page: f.left_page,
};
let nopts = ComposeOptions { page_name: opts.page_name.clone(), page: opts.page, running: opts.running.clone(), label: Some(label) };
let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts);
let h = cs.frames.first().and_then(|ft| ft.lines.last()).map_or(0.0, |l| l.baseline + l.descent);
let v = (Arc::new(cs), h);
self.cache.insert(key, v.clone());
v
}
/// Height the footnotes of column (fi, col) take, with `extra` footnotes added.
#[allow(clippy::too_many_arguments)]
pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 {
let o = &doc.footnote_options;
let mut n = 0usize;
let mut h = 0.0;
for p in self.placed.iter().filter(|p| p.fi == fi && p.col == col) {
n += 1;
h += p.h;
}
for &k in extra {
n += 1;
h += self.compose(doc, k, w, f, opts).1;
}
if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before }
}
pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) {
if self.placed.iter().any(|p| p.k == k) {
return;
}
let (text, h) = self.compose(doc, k, w, f, opts);
self.placed.push(Placed { k, fi, col, h, text });
}
/// Stack each column's footnotes against its bottom, rule above the first.
pub fn finish(&mut self, doc: &Document, story: &Story, frames: &[FrameSpec], cols: &[Vec<Rect>], out: &mut ComposedStory) {
if self.placed.is_empty() {
return;
}
let o = &doc.footnote_options;
let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, p.col)).collect();
groups.dedup();
groups.sort_unstable();
groups.dedup();
for (fi, ci) in groups {
let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).and_then(|c| c.get(ci))) else { continue };
let here: Vec<&Placed> = self.placed.iter().filter(|p| p.fi == fi && p.col == ci).collect();
let total: f64 = here.iter().map(|p| p.h).sum::<f64>() + o.space_between * (here.len() - 1) as f64;
let top = col.y1 - total;
let ft = &mut out.frames[fi];
if o.rule.on && o.rule.weight > 0.0 {
let x0 = col.x0 + o.rule.left_indent;
let y = top + o.rule.offset - o.rule.weight;
ft.decos.push(Deco {
rect: Rect::new(x0, y, (x0 + o.rule.width).min(col.x1), y + o.rule.weight),
color: o.rule.color.clone(),
tint: o.rule.tint,
});
}
let mut y = top;
for p in here {
let note = &story.notes[p.k];
ft.notes.push(PlacedNote {
id: note.id,
index: p.k,
column: ci as u32,
label: o.label(self.numbers[p.k], true),
origin: Point::new(col.x0, y),
rect: Rect::new(col.x0, y, col.x1, y + p.h),
text: p.text.clone(),
source: Arc::from(note.text.text.as_str()),
});
y += p.h + o.space_between;
}
}
}
}
/// The placed footnote with id `id`: (frame index, footnote).
pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> {
cs.frames.iter().enumerate().find_map(|(fi, ft)| ft.notes.iter().find(|n| n.id == id).map(|n| (fi, n)))
}
/// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story).
pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> {
let ft = cs.frames.get(fi)?;
let n = ft.notes.iter().find(|n| n.rect.contains(p))?;
let local = Point::new(p.x - n.origin.x, p.y - n.origin.y);
Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0)))
}
/// Caret geometry for byte `pos` of a footnote's story, in frame inner space:
/// (frame index, x, baseline, ascent, descent).
pub fn note_caret(cs: &ComposedStory, id: u64, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
let (fi, n) = find_note(cs, id)?;
match crate::caret(&n.text, pos) {
Some((_, x, bl, a, d)) => Some((fi, x + n.origin.x, bl + n.origin.y, a, d)),
None => Some((fi, n.origin.x, n.origin.y + 10.0, 9.0, 3.0)),
}
}
+35 -1
View File
@@ -68,6 +68,11 @@ pub struct SubstCtx {
pub section_marker: Option<String>,
/// Text variable values by index (on the page being composed).
pub vars: Arc<Vec<String>>,
/// Footnote reference labels by story byte of the reference character.
pub notes: HashMap<usize, String>,
/// Footnote reference formatting: position and character style (`None` = the text's).
pub note_position: designcraft_doc::Position,
pub note_style: Option<String>,
}
pub(crate) struct StyleTable<'a> {
@@ -117,7 +122,31 @@ pub(crate) fn shape_para(
}
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);
if !story.text[a..b].contains(designcraft_doc::FOOTNOTE_REF) {
shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs);
continue;
}
// Footnote references take the reference position / character style.
let mut rf = fmt.clone();
if let Some(cs) = &sub.note_style {
rf.style = cs.clone();
}
rf.over.position = Some(sub.note_position);
let rprops = styles.resolve_char(para_chars, &rf);
let rstyle = table.intern(&rprops);
let mut k = a;
for (i, _) in story.text[a..b].match_indices(designcraft_doc::FOOTNOTE_REF) {
let i = a + i;
if k < i {
shape_run(db, &story.text, k..i, &props, auto_leading, style, sub, &mut glyphs);
}
let e = i + designcraft_doc::FOOTNOTE_REF.len_utf8();
shape_run(db, &story.text, i..e, &rprops, auto_leading, rstyle, sub, &mut glyphs);
k = e;
}
if k < b {
shape_run(db, &story.text, k..b, &props, auto_leading, style, sub, &mut glyphs);
}
}
ShapedPara { glyphs, range }
}
@@ -192,6 +221,7 @@ fn shape_run(
| story::INDENT_HERE
| story::RIGHT_INDENT_TAB
| story::TABLE_ANCHOR
| designcraft_doc::FOOTNOTE_REF
) || designcraft_doc::vars::var_index(c).is_some();
if special {
flush(seg_start, i, &seg_face, out);
@@ -216,6 +246,10 @@ fn shape_run(
};
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out);
}
designcraft_doc::FOOTNOTE_REF => {
let s = sub.notes.get(&i).map_or("#", String::as_str);
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);
+54
View File
@@ -733,3 +733,57 @@ fn tab_leaders_fill_the_gap() {
let num_x = line.glyphs.iter().find(|g| g.byte == 6).unwrap().x;
assert!(last_dot < num_x, "leaders stop before the page number");
}
#[test]
fn footnotes_sit_at_the_column_bottom_and_push_text() {
// A short frame: without footnotes the text fills it; with two footnotes the body text
// makes room and the footnotes stack against the bottom with a rule above.
let text = format!("{LOREM} {LOREM} {LOREM}");
let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 236.0), ParaAttrs::default());
let plain = compose_story(&d, sid, &ComposeOptions::default());
let plain_lines = plain.frames[0].lines.len();
{
let st = d.story_mut(sid).unwrap();
st.insert_note(10, "First note, long enough to take two lines in this narrow frame for sure.", ParaFormat::default());
st.insert_note(40, "Second note.", ParaFormat::default());
st.check().unwrap();
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ft = cs.frame(fid).unwrap();
assert_eq!(ft.notes.len(), 2);
assert!(ft.lines.len() < plain_lines, "body text makes room: {} vs {plain_lines}", ft.lines.len());
let (a, b) = (&ft.notes[0], &ft.notes[1]);
assert_eq!(a.label, "1");
assert_eq!(b.label, "2");
assert!(a.rect.y1 <= b.rect.y0 + 1e-6, "stacked in order");
let area = ft.columns[0];
assert!((b.rect.y1 - area.y1).abs() < 1e-6, "last footnote ends at the column bottom");
let last = ft.lines.last().unwrap();
assert!(last.baseline + last.descent <= a.rect.y0 - d.footnote_options.space_before + 0.01, "text clears the footnotes");
assert!(a.text.frames[0].lines.len() >= 2);
// The rule sits above the first footnote.
assert!(ft.decos.iter().any(|dc| (dc.rect.width() - 72.0).abs() < 1e-6 && dc.rect.y1 <= a.rect.y0 + 1e-6));
// The reference shows its number as superscript glyphs in the text.
let refs: Vec<&PlacedGlyph> = ft.lines.iter().flat_map(|l| l.glyphs.iter()).filter(|g| g.byte == 10 && g.len > 0).collect();
assert_eq!(refs.len(), 1);
// Numbering continues from options and restarts per page when asked.
d.footnote_options.start_at = 5;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert_eq!(cs.frames[0].notes[1].label, "6");
// Hit testing and carets reach footnote text.
let n = &cs.frames[0].notes[1];
let (id, _) = hit_note(&cs, 0, Point::new(n.rect.x0 + 40.0, n.rect.y0 + 5.0)).unwrap();
assert_eq!(id, n.id);
assert!(note_caret(&cs, n.id, 0).is_some());
}
#[test]
fn footnote_line_at_column_top_still_sets() {
// A note taller than the frame can't push its reference line forever.
let (mut d, sid, _) = doc_with("Short text.", Rect::new(0.0, 0.0, 200.0, 40.0), ParaAttrs::default());
let long = LOREM.repeat(3);
d.story_mut(sid).unwrap().insert_note(5, &long, ParaFormat::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.frames[0].lines.is_empty());
assert_eq!(cs.frames[0].notes.len(), 1);
}
+7
View File
@@ -301,6 +301,8 @@ pub enum NumberStyle {
LowerLetters,
/// 01, 02, …
ArabicLeadingZero,
/// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes.
Symbols,
}
impl NumberStyle {
@@ -312,6 +314,11 @@ impl NumberStyle {
NumberStyle::LowerRoman => roman(n).to_lowercase(),
NumberStyle::UpperLetters => letters(n),
NumberStyle::LowerLetters => letters(n).to_lowercase(),
NumberStyle::Symbols => {
const SYM: [char; 6] = ['*', '†', '‡', '§', '¶', '‖'];
let n = n.max(1) as usize - 1;
std::iter::repeat_n(SYM[n % SYM.len()], n / SYM.len() + 1).collect()
}
}
}
}
+1
View File
@@ -140,6 +140,7 @@ impl Document {
user_words: vec![],
toc: None,
text_variables: crate::vars::defaults(),
footnote_options: Default::default(),
created: crate::vars::now(),
modified: 0,
next_id: 0,
+6
View File
@@ -227,6 +227,10 @@ impl Document {
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
for n in &other.notes {
let id = st.next_note_id();
st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() }));
}
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
@@ -352,6 +356,8 @@ impl Document {
st.paras = vec![para; n];
st.chars = vec![CharRun { len: text.len(), format: fmt }];
st.tables.clear();
st.notes.clear();
st.fix_notes();
st.rev += 1;
Ok(())
}
+5
View File
@@ -13,6 +13,7 @@ pub mod build;
mod edit;
pub mod ids;
pub mod item;
pub mod notes;
pub mod page;
pub mod selection;
pub mod story;
@@ -29,6 +30,7 @@ pub use designcraft_geom as geom;
pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit};
pub use ids::*;
pub use item::*;
pub use notes::{FOOTNOTE_REF, FOOTNOTE_TABLE, Footnote, FootnoteOptions};
pub use page::*;
pub use selection::*;
pub use story::*;
@@ -247,6 +249,9 @@ pub struct Document {
/// Text variable definitions (instances in stories are `vars::var_char(index)`).
#[serde(default = "vars::defaults")]
pub text_variables: Vec<vars::TextVariable>,
/// Type › Document Footnote Options.
#[serde(default)]
pub footnote_options: FootnoteOptions,
/// Creation / last save time (Unix seconds, UTC).
#[serde(default)]
pub created: i64,
+301
View File
@@ -0,0 +1,301 @@
//! Footnotes.
//!
//! A footnote reference is a [`FOOTNOTE_REF`] character in a story; the story's
//! [`Story::notes`] holds one [`Footnote`] per reference, in text order (the k-th reference
//! character owns `notes[k]`). Deleting the reference deletes the footnote; inserting text that
//! contains reference characters inserts empty footnotes. Each footnote's text is a frameless
//! [`Story`] (like a table cell) composed at the bottom of the column its reference lands in.
//!
//! Numbering and layout come from the document-wide [`FootnoteOptions`]
//! (Type › Document Footnote Options).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::attrs::{NumberStyle, Position};
use crate::ids::StoryId;
use crate::item::FirstBaseline;
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat, Story};
/// Footnote reference character (the number shown in the text).
pub const FOOTNOTE_REF: char = '\u{E00A}';
/// Footnote addresses reuse [`crate::CellAddr`] with this pseudo table id (the row is the
/// footnote id): text selections, typing and formatting reach footnote text like cell text.
pub const FOOTNOTE_TABLE: u64 = u64::MAX;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Footnote {
/// Stable within the story (selection addresses).
pub id: u64,
/// The footnote text (no frames).
pub text: Story,
}
/// Restart Numbering Every.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NoteRestart {
/// Continuous through the document.
#[default]
Never,
Page,
Spread,
Section,
}
/// Where the prefix/suffix appear.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AffixIn {
#[default]
None,
Reference,
Text,
Both,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NoteRule {
pub on: bool,
pub weight: f64,
pub color: String,
pub tint: f32,
/// Rule length.
pub width: f64,
/// Baseline offset below the top of the footnote area.
pub offset: f64,
pub left_indent: f64,
}
impl Default for NoteRule {
fn default() -> Self {
NoteRule { on: true, weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, width: 72.0, offset: 0.0, left_indent: 0.0 }
}
}
/// Type › Document Footnote Options (Numbering and Formatting, Layout).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct FootnoteOptions {
pub style: NumberStyle,
pub start_at: u32,
pub restart: NoteRestart,
pub prefix: String,
pub suffix: String,
pub affix_in: AffixIn,
/// Footnote reference number in text.
pub ref_position: Position,
pub ref_char_style: String,
/// Footnote text formatting.
pub para_style: String,
/// Between the number and the footnote text.
pub separator: String,
/// Minimum space between the text and the first footnote.
pub space_before: f64,
pub space_between: f64,
pub first_baseline: FirstBaseline,
pub first_baseline_min: f64,
/// Footnotes span all columns of a multi-column frame (else each column gets its own).
pub span_columns: bool,
/// Rule above the first footnote in a column.
pub rule: NoteRule,
}
impl Default for FootnoteOptions {
fn default() -> Self {
FootnoteOptions {
style: NumberStyle::Arabic,
start_at: 1,
restart: NoteRestart::Never,
prefix: String::new(),
suffix: String::new(),
affix_in: AffixIn::None,
ref_position: Position::Superscript,
ref_char_style: NO_CHAR_STYLE.into(),
para_style: BASIC_PARAGRAPH.into(),
separator: "\t".into(),
space_before: 0.0,
space_between: 0.0,
first_baseline: FirstBaseline::Leading,
first_baseline_min: 0.0,
span_columns: false,
rule: NoteRule::default(),
}
}
}
impl FootnoteOptions {
/// The number as shown at the reference (`text == false`) or before the footnote text.
pub fn label(&self, n: u32, text: bool) -> String {
let num = self.style.format(n);
let affix = match self.affix_in {
AffixIn::None => false,
AffixIn::Both => true,
AffixIn::Reference => !text,
AffixIn::Text => text,
};
if affix { format!("{}{num}{}", self.prefix, self.suffix) } else { num }
}
}
impl Story {
/// Index (in [`Story::notes`]) of the footnote whose reference is the k-th reference before `pos`.
pub fn notes_before(&self, pos: usize) -> usize {
let pos = crate::story::floor_char_boundary(&self.text, pos);
self.text[..pos].matches(FOOTNOTE_REF).count()
}
pub fn note(&self, id: u64) -> Option<&Footnote> {
self.notes.iter().find(|n| n.id == id).map(|n| &**n)
}
/// Mutable footnote (copy-on-write); bumps the story revision.
pub fn note_mut(&mut self, id: u64) -> Option<&mut Footnote> {
let n = self.notes.iter_mut().find(|n| n.id == id)?;
self.rev += 1;
Some(Arc::make_mut(n))
}
/// Byte offset of a footnote's reference character.
pub fn note_anchor(&self, id: u64) -> Option<usize> {
let k = self.notes.iter().position(|n| n.id == id)?;
self.text.match_indices(FOOTNOTE_REF).nth(k).map(|(i, _)| i)
}
/// Insert a footnote reference at `pos` with the given footnote text. Returns the footnote id.
pub fn insert_note(&mut self, pos: usize, text: &str, para: ParaFormat) -> u64 {
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
let fmt = self.char_format_at(pos).clone();
self.insert_with(pos, &FOOTNOTE_REF.to_string(), fmt);
let k = self.notes_before(pos);
let note = Arc::make_mut(&mut self.notes[k]);
note.text = Story::with_text(StoryId(0), text, para);
note.id
}
pub(crate) fn next_note_id(&self) -> u64 {
self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1
}
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.
pub(crate) fn notes_inserted(&mut self, k: usize, n: usize) {
for i in 0..n {
let id = self.next_note_id();
let mut text = Story::new(StoryId(0));
text.chars[0].format = CharFormat::default();
self.notes.insert((k + i).min(self.notes.len()), Arc::new(Footnote { id, text }));
}
}
/// Make the footnote list match the reference characters (after wholesale text changes).
pub fn fix_notes(&mut self) {
let n = self.text.matches(FOOTNOTE_REF).count();
if n < self.notes.len() {
self.notes.truncate(n);
} else if n > self.notes.len() {
let k = self.notes.len();
self.notes_inserted(k, n - k);
}
}
pub(crate) fn check_notes(&self) -> Result<(), String> {
let n = self.text.matches(FOOTNOTE_REF).count();
if n != self.notes.len() {
return Err(format!("story {}: {n} footnote references, {} footnotes", self.id.0, self.notes.len()));
}
let mut ids = std::collections::HashSet::new();
for note in &self.notes {
if !ids.insert(note.id) {
return Err(format!("story {}: footnote id {} used twice", self.id.0, note.id));
}
note.text.check()?;
}
Ok(())
}
}
impl crate::Document {
/// Number of the first footnote of story `sid` when numbering runs through the document:
/// the start number plus the footnotes of stories that start on earlier pages.
pub fn footnote_start(&self, sid: StoryId) -> u32 {
let o = &self.footnote_options;
if o.restart != NoteRestart::Never {
return o.start_at;
}
let key = |st: &Story| -> (usize, u64) {
let page = st.frames.first().and_then(|f| self.page_of_item(*f)).unwrap_or(usize::MAX);
(page, st.id.0)
};
let Some(me) = self.story(sid) else { return o.start_at };
let mine = key(me);
let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum();
o.start_at + before as u32
}
/// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads.
pub fn page_of_item(&self, id: crate::ItemId) -> Option<usize> {
let loc = self.find(id)?;
let crate::SpreadRef::Doc(si) = loc.spread else { return None };
let sp = self.spread(loc.spread)?;
let c = self.item(id)?.bounds().center();
Some(self.first_page_of_spread(si) + sp.page_at_x(c.x).unwrap_or(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn st(t: &str) -> Story {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
#[test]
fn references_own_notes_in_order() {
let mut s = st("alpha beta gamma");
let b = s.insert_note(10, "second", ParaFormat::default());
let a = s.insert_note(5, "first", ParaFormat::default());
s.check().unwrap();
assert_eq!(s.notes.len(), 2);
assert_eq!(s.notes[0].id, a);
assert_eq!(s.notes[0].text.text, "first");
assert_eq!(s.notes[1].text.text, "second");
assert_eq!(s.note_anchor(b), Some(10 + FOOTNOTE_REF.len_utf8()));
// Deleting the first reference deletes its note only.
s.delete(5..5 + FOOTNOTE_REF.len_utf8());
s.check().unwrap();
assert_eq!(s.notes.len(), 1);
assert_eq!(s.notes[0].id, b);
// Typing a reference character (paste) creates an empty note.
s.insert(0, &FOOTNOTE_REF.to_string());
s.check().unwrap();
assert_eq!(s.notes.len(), 2);
assert_eq!(s.notes[1].id, b);
assert!(s.notes[0].text.text.is_empty());
// Deleting a range spanning everything clears all notes.
let n = s.len();
s.delete(0..n);
s.check().unwrap();
assert!(s.notes.is_empty());
}
#[test]
fn labels_and_symbols() {
let mut o = FootnoteOptions::default();
assert_eq!(o.label(3, false), "3");
o.style = NumberStyle::Symbols;
assert_eq!(o.label(1, false), "*");
assert_eq!(o.label(2, false), "†");
assert_eq!(o.label(7, false), "**");
o.style = NumberStyle::Arabic;
o.prefix = "[".into();
o.suffix = "]".into();
o.affix_in = AffixIn::Reference;
assert_eq!(o.label(4, false), "[4]");
assert_eq!(o.label(4, true), "4");
}
}
+11
View File
@@ -14,6 +14,17 @@ pub struct CellAddr {
pub col: usize,
}
impl CellAddr {
/// The text of footnote `id` (see [`crate::notes::FOOTNOTE_TABLE`]).
pub fn footnote(id: u64) -> Self {
CellAddr { table: crate::notes::FOOTNOTE_TABLE, row: id as usize, col: 0 }
}
/// The footnote id when this addresses footnote text.
pub fn footnote_id(&self) -> Option<u64> {
(self.table == crate::notes::FOOTNOTE_TABLE).then_some(self.row as u64)
}
}
/// Selected table cells (Table > Select, dragging across cells).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+17 -2
View File
@@ -10,7 +10,7 @@
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]).
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]), [`crate::notes::FOOTNOTE_REF`] (a footnote).
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -94,6 +94,9 @@ pub struct Story {
/// Tables anchored in the story, by id (see [`crate::table`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tables: BTreeMap<u64, Arc<Table>>,
/// Footnotes, one per [`crate::notes::FOOTNOTE_REF`] in text order (see [`crate::notes`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Arc<crate::notes::Footnote>>,
}
impl Story {
@@ -106,6 +109,7 @@ impl Story {
frames: vec![],
rev: 0,
tables: BTreeMap::new(),
notes: Vec::new(),
}
}
@@ -189,6 +193,11 @@ impl Story {
return;
}
let pos = floor_char_boundary(&self.text, pos);
let refs = text.matches(crate::notes::FOOTNOTE_REF).count();
if refs > 0 {
let k = self.notes_before(pos);
self.notes_inserted(k, refs);
}
// Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it.
let pi = self.para_at(pos);
let newlines = text.bytes().filter(|b| *b == b'\n').count();
@@ -212,6 +221,11 @@ impl Story {
if a == b {
return;
}
let refs = self.text[a..b].matches(crate::notes::FOOTNOTE_REF).count();
if refs > 0 {
let k = self.notes_before(a);
self.notes.drain(k..(k + refs).min(self.notes.len()));
}
let pi = self.para_at(a);
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
// Merged paragraphs keep a table whose anchor survives the deletion.
@@ -314,7 +328,8 @@ impl Story {
return Err(format!("run boundary {pos} splits a character"));
}
}
self.check_tables()
self.check_tables()?;
self.check_notes()
}
// ---------- run helpers ----------
+2
View File
@@ -695,6 +695,7 @@ impl crate::Document {
pub fn text_story(&self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&Story> {
let st = self.story(story)?;
match cell {
Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text),
Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text),
None => Some(st),
}
@@ -704,6 +705,7 @@ impl crate::Document {
pub fn text_story_mut(&mut self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&mut Story> {
let st = self.story_mut(story)?;
match cell {
Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text),
Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text),
None => Some(st),
}
+2
View File
@@ -9,6 +9,7 @@ mod inspect;
mod interactive;
pub mod interchange;
mod layout;
mod notes;
mod object;
pub mod preflight;
pub mod spelling;
@@ -122,6 +123,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(find::specs());
v.extend(variables::specs());
v.extend(toc::specs());
v.extend(notes::specs());
v.extend(spelling::specs());
v.extend(datamerge::specs());
v.extend(interactive::specs());
+224
View File
@@ -0,0 +1,224 @@
//! Type › Insert Footnote, Document Footnote Options, footnote inspection and editing.
use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"footnote.insert",
"Insert Footnote",
["Type"],
None,
"{text?} — a footnote reference at the insertion point; the caret moves into the new footnote's text",
in_story_text,
insert
),
cmd!(
"footnote.options",
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
cmd!(query "footnote.list", "Footnotes", [], None, "{story?} → [{story, id, index, anchor, text}] in story order", has_doc, list),
cmd!("footnote.setText", "Set Footnote Text", [], None, "{story, id, text} — replaces the footnote's text", has_doc, set_text),
cmd!("footnote.delete", "Delete Footnote", [], None, "{story, id} — deletes the reference (and so the footnote)", has_doc, delete),
cmd!(
noundo "footnote.goToReference",
"Go to Footnote Reference",
["Type"],
None,
"{} — from footnote text, puts the caret after the footnote's reference",
in_note,
go_to_reference
),
]
}
fn in_story_text(s: &Session) -> std::result::Result<(), String> {
super::has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some() => Err("footnotes can't be inserted in table cells or other footnotes".into()),
_ => Ok(()),
}
}
fn in_note(s: &Session) -> std::result::Result<(), String> {
super::has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some_and(|c| c.footnote_id().is_some()) => Ok(()),
_ => Err("the insertion point is not in footnote text".into()),
}
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?;
let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() };
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = r.start.min(st.len())..r.end.min(st.len());
st.delete(r.clone());
let id = st.insert_note(r.start, &text, para);
sel.text = Some(TextSel { story: t.story, anchor: text.len(), focus: text.len(), frame: t.frame, cell: Some(CellAddr::footnote(id)) });
Ok(json!({"story": t.story.0, "id": id}))
})
}
/// Overlay `p` onto `base` (objects merge key by key).
fn merge(base: &mut Value, p: &Value) {
match (base, p) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o {
match b.get_mut(k) {
Some(slot) if slot.is_object() && v.is_object() => merge(slot, v),
_ => {
b.insert(k.clone(), v.clone());
}
}
}
}
(b, v) => *b = v.clone(),
}
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.footnote_options.clone();
if p.as_object().is_none_or(|o| o.is_empty()) {
return Ok(serde_json::to_value(&cur).unwrap_or_default());
}
let mut v = serde_json::to_value(&cur).unwrap_or_default();
merge(&mut v, p);
let next: FootnoteOptions = serde_json::from_value(v).map_err(|e| bad("footnote.options", e.to_string()))?;
let out = serde_json::to_value(&next).unwrap_or_default();
if next != cur {
s.edit(|d, _| {
d.footnote_options = next;
Ok(Value::Null)
})?;
}
Ok(out)
}
fn story_id(p: &Value, cmd: &str) -> Result<StoryId> {
p.get("story").and_then(Value::as_u64).map(StoryId).ok_or_else(|| bad(cmd, "missing story"))
}
fn note_id(p: &Value, cmd: &str) -> Result<u64> {
p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing id"))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let only = p.get("story").and_then(Value::as_u64).map(StoryId);
let mut out = Vec::new();
for st in d.stories.values().filter(|st| only.is_none_or(|o| o == st.id)) {
for (k, n) in st.notes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "index": k, "anchor": st.note_anchor(n.id), "text": n.text.text}));
}
}
Ok(Value::Array(out))
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.setText")?;
let id = note_id(p, "footnote.setText")?;
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?;
let len = n.text.len();
n.text.replace(0..len, &text);
Ok(Value::Null)
})
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.delete")?;
let id = note_id(p, "footnote.delete")?;
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let at = st.note_anchor(id).ok_or_else(|| bad("footnote.delete", format!("no footnote {id}")))?;
st.delete(at..at + designcraft_doc::FOOTNOTE_REF.len_utf8());
if sel.text.is_some_and(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))) {
sel.text = Some(TextSel::caret(sid, at));
}
Ok(Value::Null)
})
}
fn go_to_reference(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad("footnote.goToReference", "no caret"))?;
let id = t.cell.and_then(|c| c.footnote_id()).ok_or_else(|| bad("footnote.goToReference", "not in footnote text"))?;
let at = st.doc.story(t.story).and_then(|x| x.note_anchor(id)).ok_or_else(|| bad("footnote.goToReference", "footnote not found"))?;
let pos = at + designcraft_doc::FOOTNOTE_REF.len_utf8();
st.selection.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t });
st.revision += 1;
Ok(json!({"story": t.story.0, "pos": pos}))
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_text() -> (Session, StoryId) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "Body text with a note here."})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
(s, sid)
}
#[test]
fn insert_type_and_undo_footnotes() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
let r = s.execute("footnote.insert", &json!({})).unwrap();
let id = r["id"].as_u64().unwrap();
// Typing goes into the footnote.
s.execute("text.insert", &json!({"text": "A source."})).unwrap();
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(st.notes.len(), 1);
assert_eq!(st.notes[0].text.text, "A source.");
assert_eq!(st.text.matches(designcraft_doc::FOOTNOTE_REF).count(), 1);
// The footnote is laid out in the frame.
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
// Back to the reference.
let r = s.execute("footnote.goToReference", &json!({})).unwrap();
assert_eq!(r["pos"], json!(16 + designcraft_doc::FOOTNOTE_REF.len_utf8()));
// Listing, editing, options.
let l = s.execute("footnote.list", &json!({})).unwrap();
assert_eq!(l[0]["text"], "A source.");
s.execute("footnote.setText", &json!({"story": sid.0, "id": id, "text": "Changed."})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes[0].text.text, "Changed.");
let o = s.execute("footnote.options", &json!({"style": "lowerRoman", "rule": {"width": 100.0}})).unwrap();
assert_eq!(o["style"], "lowerRoman");
assert_eq!(o["rule"]["width"], 100.0);
assert_eq!(o["rule"]["on"], true);
assert!(s.execute("footnote.options", &json!({"restart": "bogus"})).is_err());
// Deleting removes the reference; undo brings everything back.
s.execute("footnote.delete", &json!({"story": sid.0, "id": id})).unwrap();
assert!(s.doc().unwrap().doc.story(sid).unwrap().notes.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes.len(), 1);
}
#[test]
fn not_in_cells() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
// Caret is now in footnote text: no nested footnotes.
assert!(s.execute("footnote.insert", &json!({})).is_err());
}
}
+4
View File
@@ -160,6 +160,9 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
if let Some((id, b)) = compose::hit_note(&cs, fi, inner) {
return Some((sid, b, Some(CellAddr::footnote(id))));
}
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
Some((sid, b, None))
}
@@ -354,6 +357,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let cs = s.cache.get(&st.doc, t.story, None);
// In a cell, lines come from the cell's own composition.
let cs = match t.cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
};
+25
View File
@@ -102,6 +102,7 @@ pub fn magazine() -> Document {
..Default::default()
});
d.title = "Quarterly — Spring Issue".into();
d.footnote_options.para_style = "Footnote".into();
// Swatches.
d.swatches.push(Swatch::color("Ink Plum", Color::cmyk(0.62, 0.95, 0.30, 0.25)));
d.swatches.push(Swatch::color("Sunset", Color::cmyk(0.0, 0.62, 0.78, 0.0)));
@@ -141,6 +142,22 @@ pub fn magazine() -> Document {
ParaAttrs { align: Some(Align::LeftJustified), first_line_indent: Some(12.0), hyph_min_word: Some(6), ..Default::default() },
CharAttrs { leading: Some(Leading::Points(13.5)), ..serif("Regular", 9.75) },
));
st.paragraph.push(para_style(
"Footnote",
Some(story::BASIC_PARAGRAPH),
ParaAttrs {
left_indent: Some(10.0),
first_line_indent: Some(-10.0),
tabs: Some(vec![designcraft_doc::TabStop {
position: 10.0,
align: Default::default(),
leader: String::new(),
align_on: String::new(),
}]),
..Default::default()
},
CharAttrs { leading: Some(Leading::Points(9.0)), ..serif("Regular", 7.0) },
));
st.paragraph.push(para_style(
"Body First",
Some("Body"),
@@ -305,6 +322,14 @@ pub fn magazine() -> Document {
let more = format!("\n{BODY}\n{BODY}");
let end = st.len();
st.insert(end, &more);
// A footnote on the last sentence of the article.
if let Some(at) = st.text.trim_end().rfind('.') {
st.insert_note(
at + 1,
"Every image, swatch and paragraph in this sample is generated in code — no stock assets.",
ParaFormat { style: "Footnote".into(), ..Default::default() },
);
}
}
// Pull quote with shading, wrapped.
let (pq, pqs) = text(
+94 -1
View File
@@ -637,6 +637,7 @@ impl<'a> Ex<'a> {
),
);
root.push(El::new("PasteboardPreference").attr("PasteboardMargins", pt(s.pasteboard.0, s.pasteboard.1)));
root.push(self.footnote_option_el());
document(&root)
}
@@ -1227,6 +1228,79 @@ impl<'a> Ex<'a> {
el
}
fn footnote_option_el(&self) -> El {
let o = &self.d.footnote_options;
let marker = if o.ref_char_style == st::NO_CHAR_STYLE {
NO_CHAR_ID.to_string()
} else {
names::style_self("CharacterStyle", CHAR_BUILTINS, &o.ref_char_style)
};
let enumv = |name: &str, v: &str| {
let mut e = El::new(name).attr("type", "enumeration");
e.children.push(Node::Text(v.into()));
e
};
let mut color = El::new("RuleColor").attr("type", "object");
color.children.push(Node::Text(self.sw(&o.rule.color)));
let mut el = El::new("FootnoteOption")
.attr("StartAt", o.start_at)
.attr("Prefix", &o.prefix)
.attr("Suffix", &o.suffix)
.attr("FootnoteTextStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &o.para_style))
.attr("FootnoteMarkerStyle", marker)
.attr("SeparatorText", &o.separator)
.attr("SpaceBetween", num(o.space_between))
.attr("Spacer", num(o.space_before))
.attr("FootnoteFirstBaselineOffset", names::first_baseline_out(o.first_baseline))
.attr("FootnoteMinimumFirstBaselineOffset", num(o.first_baseline_min))
.attr("EnableStraddling", bool_s(o.span_columns))
.attr("RuleOn", bool_s(o.rule.on))
.attr("RuleLineWeight", num(o.rule.weight))
.attr("RuleTint", num(o.rule.tint as f64 * 100.0))
.attr("RuleLeftIndent", num(o.rule.left_indent))
.attr("RuleWidth", num(o.rule.width))
.attr("RuleOffset", num(o.rule.offset));
let restart = names::NOTE_RESTART.iter().find(|r| r.0 == o.restart).map_or("DontRestart", |r| r.1);
let affix = names::NOTE_AFFIX.iter().find(|r| r.0 == o.affix_in).map_or("NoPrefixSuffix", |r| r.1);
let mut props = El::new("Properties");
props.push(enumv("FootnoteNumberingStyle", names::note_style_out(o.style)));
props.push(enumv("RestartNumbering", restart));
props.push(enumv("ShowPrefixSuffix", affix));
props.push(enumv("MarkerPositioning", names::note_marker_out(o.ref_position)));
props.push(color);
el.push(props);
el
}
/// `<Footnote>`: the footnote's paragraphs, the first starting with the footnote number marker
/// and the separator (as InDesign stores them).
fn footnote_el(&mut self, note: &Story) -> El {
let mut el = El::new("Footnote");
let mut paras = self.story_paras(note);
let lead = vec![Node::Pi("ACE".into(), "4".into()), Node::Text(self.d.footnote_options.separator.clone())];
if let Some(Node::El(csr)) =
paras.first_mut().and_then(|p| p.children.iter_mut().find(|n| matches!(n, Node::El(e) if e.name == "CharacterStyleRange")))
{
match csr.children.iter_mut().find_map(|n| match n {
Node::El(c) if c.name == "Content" => Some(c),
_ => None,
}) {
Some(c) => {
c.children.splice(0..0, lead);
}
None => {
let mut c = El::new("Content");
c.children = lead;
csr.children.insert(0, Node::El(c));
}
}
}
for p in paras {
el.push(p);
}
el
}
// ---------- stories ----------
fn story_el(&mut self, s: &Story) -> El {
@@ -1269,6 +1343,7 @@ impl<'a> Ex<'a> {
}
let nseg = segs.len();
for (k, (rr, f)) in segs.into_iter().enumerate() {
let seg_start = rr.start;
let text = &s.text[rr];
// Split at break characters.
let mut cur = String::new();
@@ -1287,7 +1362,7 @@ impl<'a> Ex<'a> {
}
};
let mut out: Vec<Node> = Vec::new();
for ch in text.chars() {
for (ci, ch) in text.char_indices() {
let ace = match ch {
st::PAGE_NUMBER | st::NEXT_PAGE_NUMBER | st::PREV_PAGE_NUMBER => Some("18"),
st::SECTION_MARKER => Some("19"),
@@ -1310,6 +1385,24 @@ impl<'a> Ex<'a> {
if let Some(t) = s.para_table(pi) {
out.push(Node::El(self.table_el(t)));
}
} else if ch == designcraft_doc::FOOTNOTE_REF {
// The reference: its own range carrying the reference position.
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);
if !out.is_empty() {
let mut c = self.csr_el(&f);
c.children.append(&mut out);
csrs.push(c);
}
if let Some(n) = s.notes.get(s.notes_before(seg_start + ci)) {
let mut c = self.csr_el(&f);
let pos = self.d.footnote_options.ref_position;
if pos != designcraft_doc::Position::Normal {
c.set("Position", names::position_out(pos));
}
c.push(self.footnote_el(&n.text));
csrs.push(c);
}
} else if let Some(bt) = brk {
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);
+74 -3
View File
@@ -115,6 +115,7 @@ struct Importer<'r> {
assets: BTreeMap<AssetId, Arc<Asset>>,
ctx: ItemCtx,
sections: Vec<Section>,
footnote_options: designcraft_doc::FootnoteOptions,
}
fn lab_to_color(l: f32, a: f32, b: f32) -> Color {
@@ -180,6 +181,7 @@ impl<'r> Importer<'r> {
assets: BTreeMap::new(),
ctx: ItemCtx { threads: Vec::new() },
sections: Vec::new(),
footnote_options: Default::default(),
}
}
@@ -223,6 +225,9 @@ impl<'r> Importer<'r> {
}
self.graphics(top);
self.styles(top);
if let Some(e) = top.iter().find(|e| e.local() == "FootnoteOption") {
self.footnote_option(e);
}
for e in top.iter().filter(|e| e.local() == "Layer") {
let id = LayerId(self.alloc());
let color = match e.prop_el("LayerColor") {
@@ -650,6 +655,54 @@ impl<'r> Importer<'r> {
}
}
/// Preferences › `FootnoteOption` (after styles: it names the footnote text and marker styles).
fn footnote_option(&mut self, e: &El) {
let mut o = designcraft_doc::FootnoteOptions::default();
if let Some(v) = e.num("StartAt") {
o.start_at = v.max(0.0) as u32;
}
o.prefix = e.get("Prefix").unwrap_or("").into();
o.suffix = e.get("Suffix").unwrap_or("").into();
if let Some(r) = e.get("FootnoteTextStyle") {
o.para_style = self.para_style_ref(r);
}
if let Some(r) = e.get("FootnoteMarkerStyle") {
o.ref_char_style = self.char_style_ref(r);
}
if let Some(v) = e.get("SeparatorText") {
o.separator = v.into();
}
o.space_between = e.num("SpaceBetween").unwrap_or(0.0);
o.space_before = e.num("Spacer").unwrap_or(0.0);
if let Some(v) = e.prop("FootnoteFirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v.trim());
}
o.first_baseline_min = e.num("FootnoteMinimumFirstBaselineOffset").unwrap_or(0.0);
o.span_columns = e.boolean("EnableStraddling").unwrap_or(false);
o.rule.on = e.boolean("RuleOn").unwrap_or(true);
o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0);
o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0);
o.rule.width = e.num("RuleWidth").unwrap_or(72.0);
o.rule.offset = e.num("RuleOffset").unwrap_or(0.0);
if let Some(c) = e.prop("RuleColor") {
o.rule.color = self.swatch_ref(c.trim());
}
if let Some(v) = e.prop("FootnoteNumberingStyle") {
o.style = names::note_style_in(v.trim());
}
if let Some(v) = e.prop("RestartNumbering") {
o.restart = names::NOTE_RESTART.iter().find(|r| r.1 == v.trim()).map_or(Default::default(), |r| r.0);
}
if let Some(v) = e.prop("ShowPrefixSuffix") {
o.affix_in = names::NOTE_AFFIX.iter().find(|r| r.1 == v.trim()).map_or(Default::default(), |r| r.0);
}
if let Some(v) = e.prop("MarkerPositioning") {
o.ref_position = names::note_marker_in(v.trim());
}
self.footnote_options = o;
}
fn para_style_ref(&self, r: &str) -> String {
self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/"))))
}
@@ -827,9 +880,10 @@ impl<'r> Importer<'r> {
last: CharFormat::default(),
tables: Vec::new(),
after_table: None,
notes: Vec::new(),
};
self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None);
let StoryBuilder { text, mut paras, runs, last, tables: tbls, .. } = b;
let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, .. } = b;
let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();
if chars.is_empty() {
chars.push(CharRun { len: 0, format: last });
@@ -841,7 +895,9 @@ impl<'r> Importer<'r> {
tables.insert(t.id, Arc::new(t));
}
}
let mut st = Story { id, text, paras, chars, frames: vec![], rev: 0, tables };
let notes = notes.into_iter().enumerate().map(|(i, text)| Arc::new(designcraft_doc::Footnote { id: i as u64 + 1, text })).collect();
let mut st = Story { id, text, paras, chars, frames: vec![], rev: 0, tables, notes };
st.fix_notes();
// Drop anything inconsistent (e.g. a table sharing a paragraph with another).
if st.check().is_err() {
st.tables.retain(|_, _| false);
@@ -1022,9 +1078,21 @@ impl<'r> Importer<'r> {
let t = self.table(c);
b.push_table(t, pf, cf);
}
"Footnote" => {
// The text starts with the number marker (ACE 4, dropped) and the separator.
let mut note = self.story(StoryId(0), c);
let sep = self.footnote_options.separator.clone();
if !sep.is_empty() && note.text.starts_with(&sep) {
note.delete(0..sep.len());
}
b.notes.push(note);
// The reference's position comes from the footnote options.
let mut rcf = cf.clone();
rcf.over.position = None;
b.push(&designcraft_doc::FOOTNOTE_REF.to_string(), &rcf);
}
// Not supported yet: skip their content entirely.
"Properties"
| "Footnote"
| "Note"
| "Rectangle"
| "Oval"
@@ -1488,6 +1556,7 @@ impl<'r> Importer<'r> {
user_words: vec![],
toc: None,
text_variables: designcraft_doc::vars::defaults(),
footnote_options: std::mem::take(&mut self.footnote_options),
created: designcraft_doc::vars::now(),
modified: 0,
next_id: self.next_id,
@@ -1516,6 +1585,8 @@ struct StoryBuilder {
tables: Vec<(usize, Table)>,
/// Content after a table anchor starts a new paragraph (with this format).
after_table: Option<ParaFormat>,
/// Footnote texts in reference order.
notes: Vec<Story>,
}
impl StoryBuilder {
+44
View File
@@ -391,6 +391,7 @@ pub fn number_style_out(n: NumberStyle) -> &'static str {
NumberStyle::LowerRoman => "LowerRoman",
NumberStyle::UpperLetters => "UpperLetters",
NumberStyle::LowerLetters => "LowerLetters",
NumberStyle::Symbols => "Symbols",
}
}
pub fn number_style_in(s: &str) -> NumberStyle {
@@ -399,6 +400,7 @@ pub fn number_style_in(s: &str) -> NumberStyle {
"LowerRoman" => NumberStyle::LowerRoman,
"UpperLetters" => NumberStyle::UpperLetters,
"LowerLetters" => NumberStyle::LowerLetters,
"Symbols" => NumberStyle::Symbols,
_ => NumberStyle::Arabic,
}
}
@@ -462,3 +464,45 @@ pub fn num(v: f64) -> String {
pub fn pt(x: f64, y: f64) -> String {
format!("{} {}", num(x), num(y))
}
// ---------- footnote options ----------
pub fn note_style_out(n: NumberStyle) -> &'static str {
match n {
NumberStyle::ArabicLeadingZero => "SingleLeadingZeros",
n => number_style_out(n),
}
}
pub fn note_style_in(s: &str) -> NumberStyle {
match s {
"SingleLeadingZeros" | "DoubleLeadingZeros" => NumberStyle::ArabicLeadingZero,
"Asterisks" => NumberStyle::Symbols,
s => number_style_in(s),
}
}
pub const NOTE_RESTART: [(designcraft_doc::notes::NoteRestart, &str); 4] = [
(designcraft_doc::notes::NoteRestart::Never, "DontRestart"),
(designcraft_doc::notes::NoteRestart::Page, "PageRestart"),
(designcraft_doc::notes::NoteRestart::Spread, "SpreadRestart"),
(designcraft_doc::notes::NoteRestart::Section, "SectionRestart"),
];
pub const NOTE_AFFIX: [(designcraft_doc::notes::AffixIn, &str); 4] = [
(designcraft_doc::notes::AffixIn::None, "NoPrefixSuffix"),
(designcraft_doc::notes::AffixIn::Reference, "PrefixSuffixReference"),
(designcraft_doc::notes::AffixIn::Text, "PrefixSuffixMarker"),
(designcraft_doc::notes::AffixIn::Both, "PrefixSuffixBoth"),
];
pub fn note_marker_out(p: Position) -> &'static str {
match p {
Position::Superscript | Position::OtSuperscript => "SuperscriptMarker",
Position::Subscript | Position::OtSubscript => "SubscriptMarker",
_ => "NormalMarker",
}
}
pub fn note_marker_in(s: &str) -> Position {
match s {
"SuperscriptMarker" => Position::Superscript,
"SubscriptMarker" => Position::Subscript,
_ => Position::Normal,
}
}
+62
View File
@@ -182,6 +182,18 @@ fn fixture() -> Vec<u8> {
])
}
fn fixture_with_story(story: &str) -> Vec<u8> {
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
])
}
#[test]
fn imports_hand_written_fixture() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
@@ -364,3 +376,53 @@ fn round_trips_tables() {
assert!((bt.columns[0].width - 50.0).abs() < 1e-6);
assert_eq!(bt.options.alt_rows.as_ref().map(|a| a.first_color.as_str()), Some("Brand"));
}
#[test]
fn round_trips_footnotes_and_options() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Body text here.", ParaFormat::default()).unwrap();
{
let s = d.story_mut(sid).unwrap();
s.insert_note(4, "First note.", ParaFormat::default());
s.insert_note(9, "Second\nnote.", ParaFormat::default());
}
d.footnote_options.style = designcraft_doc::NumberStyle::LowerRoman;
d.footnote_options.restart = designcraft_doc::notes::NoteRestart::Page;
d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Both;
d.footnote_options.prefix = "[".into();
d.footnote_options.rule.width = 100.0;
d.footnote_options.space_before = 4.0;
d.check().unwrap();
let bytes = export_idml(&d);
let back = import_idml(&bytes).unwrap();
back.check().unwrap();
let s = back.stories.values().next().unwrap();
assert_eq!(s.text, d.story(sid).unwrap().text);
assert_eq!(s.notes.len(), 2);
assert_eq!(s.notes[0].text.text, "First note.");
assert_eq!(s.notes[1].text.text, "Second\nnote.");
assert_eq!(back.footnote_options, d.footnote_options);
}
/// The structure InDesign writes: the footnote in its own superscript range, its text starting
/// with the number marker and the separator.
#[test]
fn imports_indesign_style_footnote() {
let story = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Body text with a</Content></CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" Position="Superscript"><Footnote>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content><?ACE 4?>&#x9;A source note.</Content></CharacterStyleRange></ParagraphStyleRange>
</Footnote></CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content> note here.</Content></CharacterStyleRange>
</ParagraphStyleRange></Story></idPkg:Story>"#;
let d = import_idml(&fixture_with_story(story)).unwrap();
d.check().unwrap();
let s = d.stories.values().find(|s| !s.notes.is_empty()).expect("story with a footnote");
assert_eq!(s.text, format!("Body text with a{} note here.", designcraft_doc::FOOTNOTE_REF));
assert_eq!(s.notes[0].text.text, "A source note.");
// One run: the reference takes its position from the options, not an override.
assert_eq!(s.chars.len(), 1);
}
+7
View File
@@ -106,6 +106,13 @@ impl Exporter<'_> {
self.fill_rect(s, r, c);
}
}
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
self.frame_text(s, &n.text, nft, &n.source);
s.pop();
}
}
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
+5
View File
@@ -263,6 +263,11 @@ impl Renderer {
ctx.set_paint(color_of(&designcraft_color::Color::gray(0.35), 0.5));
ctx.fill_path(&greek);
}
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
self.draw_text(ctx, f, &n.text, nft, xf * Affine::translate(n.origin.to_vec2()));
}
}
}
}
+12 -4
View File
@@ -783,13 +783,18 @@ fn draw_cell_selection(
ts: designcraft_doc::TextSel,
cell: designcraft_doc::CellAddr,
) {
let Some((fi, _, pc)) = compose::find_cell(cs, cell.table, cell.row, cell.col) else { return };
// Footnote text or a cell: its composed story and where it sits in the frame.
let found = match cell.footnote_id() {
Some(id) => compose::find_note(cs, id).map(|(fi, n)| (fi, n.origin, n.text.clone())),
None => compose::find_cell(cs, cell.table, cell.row, cell.col).map(|(fi, _, pc)| (fi, pc.origin, pc.text.clone())),
};
let Some((fi, origin, text)) = found else { return };
let Some(ft) = cs.frames.get(fi) else { return };
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
let m = a * it.xf * designcraft_geom::Affine::translate(pc.origin.to_vec2());
let m = a * it.xf * designcraft_geom::Affine::translate(origin.to_vec2());
let range = ts.range();
if !range.is_empty()
&& let Some(cft) = pc.text.frames.first()
&& let Some(cft) = text.frames.first()
{
for l in &cft.lines {
let s = range.start.max(l.range.start);
@@ -809,7 +814,10 @@ fn draw_cell_selection(
}
}
if ts.is_caret()
&& let Some((_, x, bl, asc, desc)) = compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus)
&& let Some((_, x, bl, asc, desc)) = match cell.footnote_id() {
Some(id) => compose::note_caret(cs, id, ts.focus),
None => compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus),
}
{
let m = a * it.xf;
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
+6
View File
@@ -103,6 +103,12 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
"ui.menu.invoke" if p.get("params").is_none_or(Value::is_null) => {
// Exactly like choosing the menu item (commands ending in "…" open their dialog).
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
menus::activate(app, id, &Value::Null);
ok(json!({"dialog": app.ui.dialog.as_ref().map(|d| d.id.clone())}))
}
"engine.execute" | "ui.menu.invoke" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().unwrap_or(json!({}));
+363 -1
View File
@@ -37,6 +37,17 @@ impl Dialog {
fields.entry(k).or_insert(v);
}
}
if id == "footnoteOptions" {
// Flatten the rule and show special characters as InDesign metacharacters.
if let Some(Value::Object(r)) = fields.remove("rule") {
for (k, v) in r {
fields.insert(format!("rule.{k}"), v);
}
}
let sep = fields.get("separator").and_then(Value::as_str).unwrap_or("").to_string();
fields.insert("separator".into(), json!(sep.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>")));
fields.entry("tab".to_string()).or_insert(json!("numbering"));
}
if id == "paragraphStyleOptions" {
fields.entry("section".to_string()).or_insert(json!("general"));
}
@@ -63,6 +74,14 @@ impl Dialog {
_ => None,
}
}
/// A measure in points ("6", "6 pt", "0p6", "2 mm").
fn pt(&self, k: &str) -> Option<f64> {
match self.fields.get(k) {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => parse_measure(s, Unit::Points).ok(),
_ => None,
}
}
fn b(&self, k: &str) -> bool {
self.fields.get(k).and_then(Value::as_bool).unwrap_or(false)
}
@@ -101,7 +120,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"documentSetup" => "Document Setup",
"findChange" => "Find/Change",
"paragraphStyleOptions" => "Paragraph Style Options",
_ => "Dialog",
"footnoteOptions" => "Footnote Options",
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
Some(c) => c.label.trim_end_matches('…'),
None => "Dialog",
},
};
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
ui.set_min_width(380.0);
@@ -228,6 +251,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
}
"paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d),
"footnoteOptions" => footnote_options(app, ui, &mut d),
id if id.starts_with("cmd:") => command_form(ui, &mut d),
"goToPage" => {
ui.horizontal(|ui| {
ui.label("Page");
@@ -376,7 +401,55 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
app.run("style.paragraph.edit", params)
}
"footnoteOptions" => {
let pt = |k: &str| d.pt(k).map_or(Value::Null, |v| json!(v));
let mut p = json!({
"style": d.s("style"), "startAt": d.n("startAt").unwrap_or(1.0).max(0.0) as u32, "restart": d.s("restart"),
"prefix": d.s("prefix"), "suffix": d.s("suffix"), "affixIn": d.s("affixIn"), "refPosition": d.s("refPosition"),
"refCharStyle": d.s("refCharStyle"), "paraStyle": d.s("paraStyle"),
"separator": d.s("separator").replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}"),
"spaceBefore": pt("spaceBefore"), "spaceBetween": pt("spaceBetween"), "firstBaseline": d.s("firstBaseline"),
"firstBaselineMin": pt("firstBaselineMin"), "spanColumns": d.b("spanColumns"),
"rule": {"on": d.b("rule.on"), "weight": pt("rule.weight"), "color": d.s("rule.color"), "width": pt("rule.width"),
"offset": pt("rule.offset"), "leftIndent": pt("rule.leftIndent")},
});
// Unparsable measures keep their current values.
if let Some(o) = p.as_object_mut() {
o.retain(|_, v| !v.is_null());
}
if let Some(r) = p.get_mut("rule").and_then(Value::as_object_mut) {
r.retain(|_, v| !v.is_null());
}
app.run("footnote.options", p)
}
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
id if id.starts_with("cmd:") => {
let cid = &id[4..];
let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params);
let mut p = Map::new();
for f in command_fields(doc) {
match d.fields.get(&f.key) {
Some(Value::String(v)) if !v.trim().is_empty() => {
let v = v.trim();
// Numbers, booleans, arrays and objects as JSON; anything else is a string.
let parsed = serde_json::from_str::<Value>(v).ok().filter(|x| !x.is_string());
p.insert(f.key, parsed.unwrap_or_else(|| json!(v)));
}
Some(Value::Bool(b)) => {
p.insert(f.key, json!(b));
}
_ => {}
}
}
let r = app.run(cid, Value::Object(p));
if let Err(e) = &r {
// Keep the dialog open with the error.
let mut d = d.clone();
d.fields.insert("status".into(), json!(e));
app.ui.dialog = Some(d);
}
r
}
other => Err(format!("unknown dialog {other}")),
}
}
@@ -587,3 +660,292 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
});
}
fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {
let cur = d.s(key);
let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string();
egui::ComboBox::from_id_salt(key).selected_text(shown).width(170.0).show_ui(ui, |ui| {
for (v, label) in opts {
if ui.selectable_label(cur == *v, *label).clicked() {
d.fields.insert(key.into(), json!(v));
}
}
});
}
/// Style name picker (paragraph or character styles of the active document).
fn style_combo(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str, character: bool) {
let names: Vec<String> = app
.session
.active()
.map(|st| {
if character {
st.doc.styles.character.iter().map(|s| s.name.clone()).collect()
} else {
st.doc.styles.paragraph.iter().map(|s| s.name.clone()).collect()
}
})
.unwrap_or_default();
let mut opts: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), n.as_str())).collect();
if character && !names.iter().any(|n| n == designcraft_doc::NO_CHAR_STYLE) {
opts.insert(0, (designcraft_doc::NO_CHAR_STYLE, designcraft_doc::NO_CHAR_STYLE));
}
combo(ui, d, key, &opts);
}
/// Document Footnote Options: Numbering and Formatting / Layout tabs.
fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
for (tab, label) in [("numbering", "Numbering and Formatting"), ("layout", "Layout")] {
if ui.selectable_label(d.s("tab") == tab, label).clicked() {
d.fields.insert("tab".into(), json!(tab));
}
}
});
ui.separator();
let head = |ui: &mut egui::Ui, t: &str| {
ui.add_space(4.0);
ui.label(egui::RichText::new(t).font(semibold(12.0)));
};
if d.s("tab") == "layout" {
head(ui, "Spacing Options");
egui::Grid::new("fn_sp").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Minimum Space Before First Footnote:");
text_field(ui, d, "spaceBefore", 70.0);
ui.end_row();
ui.label("Space Between Footnotes:");
text_field(ui, d, "spaceBetween", 70.0);
ui.end_row();
});
head(ui, "First Baseline");
egui::Grid::new("fn_fb").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Offset:");
combo(
ui,
d,
"firstBaseline",
&[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")],
);
ui.end_row();
ui.label("Min:");
text_field(ui, d, "firstBaselineMin", 70.0);
ui.end_row();
});
check(ui, d, "spanColumns", "Span Footnotes Across Columns");
head(ui, "Rule Above");
check(ui, d, "rule.on", "Rule On");
egui::Grid::new("fn_rule").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Weight:");
text_field(ui, d, "rule.weight", 60.0);
ui.label("Color:");
let swatches: Vec<String> = app.session.active().map(|st| st.doc.swatches.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();
let opts: Vec<(&str, &str)> = swatches.iter().map(|n| (n.as_str(), n.as_str())).collect();
combo(ui, d, "rule.color", &opts);
ui.end_row();
ui.label("Width:");
text_field(ui, d, "rule.width", 60.0);
ui.label("Offset:");
text_field(ui, d, "rule.offset", 60.0);
ui.end_row();
ui.label("Left Indent:");
text_field(ui, d, "rule.leftIndent", 60.0);
ui.end_row();
});
return;
}
head(ui, "Numbering");
egui::Grid::new("fn_num").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Style:");
combo(
ui,
d,
"style",
&[
("arabic", "1, 2, 3, 4..."),
("upperRoman", "I, II, III, IV..."),
("lowerRoman", "i, ii, iii, iv..."),
("upperLetters", "A, B, C, D..."),
("lowerLetters", "a, b, c, d..."),
("arabicLeadingZero", "01, 02, 03..."),
("symbols", "*, †, ‡, §..."),
],
);
ui.end_row();
ui.label("Start at:");
text_field(ui, d, "startAt", 60.0);
ui.end_row();
ui.label("Restart Numbering Every:");
combo(ui, d, "restart", &[("never", "Never (continuous)"), ("page", "Page"), ("spread", "Spread"), ("section", "Section")]);
ui.end_row();
ui.label("Show Prefix/Suffix in:");
combo(ui, d, "affixIn", &[("none", "None"), ("reference", "Footnote Reference"), ("text", "Footnote Text"), ("both", "Both")]);
ui.end_row();
ui.label("Prefix:");
text_field(ui, d, "prefix", 60.0);
ui.end_row();
ui.label("Suffix:");
text_field(ui, d, "suffix", 60.0);
ui.end_row();
});
head(ui, "Footnote Reference Number in Text");
egui::Grid::new("fn_ref").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Position:");
combo(
ui,
d,
"refPosition",
&[
("superscript", "Apply Superscript"),
("subscript", "Apply Subscript"),
("normal", "Apply Normal"),
("otSuperscript", "OpenType Superscript"),
],
);
ui.end_row();
ui.label("Character Style:");
style_combo(app, ui, d, "refCharStyle", true);
ui.end_row();
});
head(ui, "Footnote Formatting");
egui::Grid::new("fn_fmt").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Paragraph Style:");
style_combo(app, ui, d, "paraStyle", false);
ui.end_row();
ui.label("Separator:");
text_field(ui, d, "separator", 60.0);
ui.end_row();
});
}
/// One parameter of a command, parsed from its params documentation
/// (`{name, type?: a|b|c, count?, flag?: bool, …}`).
#[derive(Clone, Debug, PartialEq)]
pub struct CommandField {
pub key: String,
pub optional: bool,
/// Enumerated values (`a|b|c`).
pub choices: Vec<String>,
pub boolean: bool,
/// The documented value spec (shown as a hint).
pub hint: String,
}
/// Parameters of a command from its documentation string: the top-level keys of the first `{…}`.
pub fn command_fields(doc: &str) -> Vec<CommandField> {
let Some(start) = doc.find('{') else { return vec![] };
let mut depth = 0i32;
let mut end = doc.len();
for (i, c) in doc[start..].char_indices() {
match c {
'{' | '[' | '(' => depth += 1,
'}' | ']' | ')' => {
depth -= 1;
if depth == 0 {
end = start + i;
break;
}
}
_ => {}
}
}
let body = &doc[start + 1..end];
let mut parts = Vec::new();
let (mut depth, mut from) = (0i32, 0usize);
for (i, c) in body.char_indices() {
match c {
'{' | '[' | '(' => depth += 1,
'}' | ']' | ')' => depth -= 1,
',' if depth == 0 => {
parts.push(&body[from..i]);
from = i + 1;
}
_ => {}
}
}
parts.push(&body[from..]);
let ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !s.starts_with(|c: char| c.is_ascii_digit());
let mut out = Vec::new();
for part in parts {
let part = part.trim();
let (k, spec) = match part.split_once(':') {
Some((k, v)) => (k.trim(), v.trim()),
None => (part, ""),
};
let optional = k.ends_with('?');
let key = k.trim_end_matches('?');
if !ident(key) || out.iter().any(|f: &CommandField| f.key == key) {
continue;
}
let word = spec.split_whitespace().next().unwrap_or("");
let choices: Vec<String> =
if word.contains('|') && word.split('|').all(ident) { word.split('|').map(str::to_string).collect() } else { vec![] };
let boolean = spec.starts_with("bool") || spec.starts_with("true|false") || spec.starts_with("true") && spec.len() <= 5;
out.push(CommandField { key: key.into(), optional, choices: if boolean { vec![] } else { choices }, boolean, hint: spec.into() });
}
out
}
fn humanize(key: &str) -> String {
let mut s = String::new();
for (i, c) in key.chars().enumerate() {
if i == 0 {
s.extend(c.to_uppercase());
} else if c.is_uppercase() {
s.push(' ');
s.extend(c.to_lowercase());
} else {
s.push(c);
}
}
s
}
/// A form for any command, from its parameter documentation.
fn command_form(ui: &mut egui::Ui, d: &mut Dialog) {
let doc = designcraft_engine::find_command(&d.id[4..]).map_or("", |c| c.params);
let fields = command_fields(doc);
let dim = crate::theme::Tokens::get(ui.ctx()).text_dim;
egui::Grid::new("cmdform").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
for f in &fields {
ui.label(format!("{}{}", humanize(&f.key), if f.optional { "" } else { " *" }));
if f.boolean {
check(ui, d, &f.key, "");
} else if !f.choices.is_empty() {
let mut opts: Vec<(&str, &str)> = vec![("", "—")];
opts.extend(f.choices.iter().map(|c| (c.as_str(), c.as_str())));
combo(ui, d, &f.key, &opts);
} else {
let mut s = d.s(&f.key);
let hint = if f.hint.is_empty() { String::new() } else { f.hint.clone() };
if ui.add(egui::TextEdit::singleline(&mut s).hint_text(egui::RichText::new(hint).color(dim)).desired_width(240.0)).changed() {
d.fields.insert(f.key.clone(), Value::String(s));
}
}
ui.end_row();
}
});
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_command_params() {
let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates");
let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect();
assert_eq!(keys, ["name", "type", "text", "rule", "flag"]);
assert_eq!(f[1].choices, ["custom", "lastPageNumber", "chapterNumber"]);
assert!(!f[0].optional && f[2].optional);
assert!(f[4].boolean);
assert!(command_fields("{}").is_empty());
assert!(command_fields("no params").is_empty());
// Every command with a "…" label parses without panicking.
for c in designcraft_engine::command_specs() {
let _ = command_fields(c.params);
}
}
}
+28 -2
View File
@@ -17,6 +17,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"),
("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"),
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
@@ -125,6 +126,10 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:type.sizeDown",
"-",
"ui:view.hiddenCharacters",
"-",
"cmd:footnote.insert",
"ui:app.footnoteOptionsDialog",
"cmd:footnote.goToReference",
],
),
(
@@ -283,6 +288,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("insertTable", p.clone()));
Ok(Value::Null)
}
"app.footnoteOptionsDialog" => {
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
let o = serde_json::to_value(&st.doc.footnote_options).unwrap_or_default();
app.ui.dialog = Some(crate::dialogs::Dialog::new("footnoteOptions", o));
Ok(Value::Null)
}
"app.tablePanel" => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) };
Ok(Value::Null)
@@ -602,6 +613,22 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
enabled(app, id)
}
/// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters
/// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]).
pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
if params.is_null()
&& ui_label(id).is_none()
&& let Some(c) = designcraft_engine::find_command(id)
&& c.label.ends_with('…')
&& !crate::dialogs::command_fields(c.params).is_empty()
{
app.ui.dialog = Some(crate::dialogs::Dialog::new(&format!("cmd:{id}"), json!({})));
return;
}
let p = if params.is_null() { json!({}) } else { params.clone() };
let _ = app.run(id, p);
}
/// The menu bar contents (inside the app bar; macOS uses the native menu instead).
pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
for (menu, entries) in menu_tree() {
@@ -632,8 +659,7 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item]) {
b = b.shortcut_text(shortcut_text(sc));
}
if ui.add_enabled(menu_enabled(app, id), b).clicked() {
let p = if params.is_null() { json!({}) } else { params.clone() };
let _ = app.run(id, p);
activate(app, id, params);
ui.close();
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The serve
| Method | Params | What it does |
|---|---|---|
| `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`) |
| `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog |
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled |
| `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection |
| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf |