diff --git a/ROADMAP.md b/ROADMAP.md index 9efad47df..2fd501ff1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, `<>` 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) | diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 625087e00..803ce4c45 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -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(), ¶ms.clone()); } } let now = designcraft_ui_egui::now_ms(); diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index e88d32b0c..4171d2aa2 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -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 { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 819e55eb9..22f05c3e5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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, + /// Footnotes placed at the bottom of this frame's columns. + pub notes: Vec, +} + +/// A footnote composed at the bottom of a column. +#[derive(Clone, Debug)] +pub struct PlacedNote { + /// Footnote id (in the story's [`Story::notes`]) and its index there. + pub id: u64, + pub index: usize, + pub column: u32, + /// The number shown. + pub label: String, + /// Where the footnote's composed text (its frame 0) sits in the frame. + pub origin: Point, + /// Area of the footnote in frame inner space. + pub rect: Rect, + pub text: Arc, + /// The footnote's story text (exporters map glyphs back to text). + pub source: Arc, } #[derive(Clone, Debug, Default)] @@ -183,6 +206,8 @@ pub struct ComposeOptions { pub page: Option, /// Running-header index (needed when the story contains running-header variables). pub running: Option>, + /// Generated text before the first paragraph (a footnote's number and separator). + pub label: Option, } /// 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)> = Vec::with_capacity(np); let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); 'paras: while pi < np { let prange = para_ranges[pi].clone(); let snap = Snapshot::take(&out, &cur, list_counter); snaps.truncate(pi); snaps.push(snap); + note_snaps.truncate(pi); + note_snaps.push((notes.num, notes.key, notes.placed.len())); info.truncate(pi); let pf = &story.paras[pi]; let (pp, base_chars) = doc.styles.resolve_para(pf); @@ -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]; diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs new file mode 100644 index 000000000..d4ca625b6 --- /dev/null +++ b/crates/compose/src/notes.rs @@ -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, +} + +pub(crate) struct Notes { + /// Byte offsets of the reference characters (index = footnote index). + refs: Vec, + /// Number of each footnote (assigned when its paragraph is shaped). + numbers: Vec, + /// Next number and the restart unit (page / spread / section start) it counts in. + pub num: u32, + pub key: Option, + pub placed: Vec, + /// Composed footnote text by (index, label, width). + cache: HashMap<(usize, String, u64), (Arc, f64)>, + notes: Vec>, +} + +impl Notes { + pub fn new(doc: &Document, story: &Story) -> Notes { + let refs: Vec = 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, page: Option, 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 { + let mut v: Vec = 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, 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], 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::() + 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)), + } +} diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 58d9b9dc5..0e1b15b69 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -68,6 +68,11 @@ pub struct SubstCtx { pub section_marker: Option, /// Text variable values by index (on the page being composed). pub vars: Arc>, + /// Footnote reference labels by story byte of the reference character. + pub notes: HashMap, + /// Footnote reference formatting: position and character style (`None` = the text's). + pub note_position: designcraft_doc::Position, + pub note_style: Option, } 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); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 73bd778a4..f8f8318bc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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); +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 4dab9e458..7836658ce 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -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() + } } } } diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 451628c55..d071b2171 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -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, diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 06655f37a..d4ef98c1a 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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(()) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bc8bd26cb..6b6e38d8b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, + /// Type › Document Footnote Options. + #[serde(default)] + pub footnote_options: FootnoteOptions, /// Creation / last save time (Unix seconds, UTC). #[serde(default)] pub created: i64, diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs new file mode 100644 index 000000000..a38020ca5 --- /dev/null +++ b/crates/doc/src/notes.rs @@ -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 { + 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 { + 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"); + } +} diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index fbe4b9132..d9e9b7a3d 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -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 { + (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")] diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 132315898..11de1d7fc 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -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>, + /// Footnotes, one per [`crate::notes::FOOTNOTE_REF`] in text order (see [`crate::notes`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec>, } 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 ---------- diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 853cf6a61..38caa5f5c 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -695,6 +695,7 @@ impl crate::Document { pub fn text_story(&self, story: StoryId, cell: Option) -> 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) -> 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), } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7b8593576..a93b9ab0a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs new file mode 100644 index 000000000..4d7b0f13f --- /dev/null +++ b/crates/engine/src/cmd/notes.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing id")) +} + +fn list(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 7c7b90710..a88624334 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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 { 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, }; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index e6689e0ae..467fc1f99 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -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( diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 26f6460ec..4161a4d17 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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 + } + + /// ``: 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 = 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); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 6489d9c2d..060fa17e3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -115,6 +115,7 @@ struct Importer<'r> { assets: BTreeMap>, ctx: ItemCtx, sections: Vec
, + 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 = 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, + /// Footnote texts in reference order. + notes: Vec, } impl StoryBuilder { diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index d41a81c8a..878c84103 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -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, + } +} diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 33395750f..1c799e03b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -182,6 +182,18 @@ fn fixture() -> Vec { ]) } +fn fixture_with_story(story: &str) -> Vec { + 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#" + + +Body text with a + + A source note. + + note here. +"#; + 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); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 5a7930f00..cb9521e15 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -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. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 98f0b1005..988687576 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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())); + } + } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 540fd58d2..9af474345 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1d5975d17..09f9441b5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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!({})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 77fe43009..c50c38e6e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { + 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 { } 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::(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 = 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 = 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, + 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 { + 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 = + 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); + } + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index edb683e32..8733dd2cf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + 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(); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d1a9ef798..8beab07b7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -10,7 +10,7 @@ Start the app with `--control ` (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 |