mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 21:26:48 -06:00
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:
co-authored by
Claude Opus 5.5
parent
7bf8167638
commit
3ad71d5a76
+3
-2
@@ -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) |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ----------
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?>	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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user