mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
267 lines
15 KiB
Rust
267 lines
15 KiB
Rust
//! Type → Text Variables: define, list, delete and insert variables.
|
|
|
|
use designcraft_doc::vars::{TextVariable, VarKind, var_char};
|
|
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!(query "variables.list", "Text Variables", [], None, "{} → [{index, name, type, …, value (page 1)}]", has_doc, |s, _| {
|
|
let d = &s.doc()?.doc;
|
|
Ok(Value::Array(
|
|
d.text_variables
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| {
|
|
let mut o = serde_json::to_value(v).unwrap_or_default();
|
|
o["index"] = json!(i);
|
|
o["value"] = json!(d.variable_value(i, Some(0)));
|
|
o
|
|
})
|
|
.collect(),
|
|
))
|
|
}),
|
|
cmd!(
|
|
"variables.define",
|
|
"Define Text Variable…",
|
|
["Type", "Text Variables"],
|
|
None,
|
|
"{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text?, format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name",
|
|
has_doc,
|
|
define
|
|
),
|
|
cmd!(
|
|
"variables.delete",
|
|
"Delete Text Variable",
|
|
[],
|
|
None,
|
|
"{name} — instances become plain text of their current value",
|
|
has_doc,
|
|
|s, p| {
|
|
let name = str_param(p, "name").ok_or_else(|| bad("variables.delete", "missing name"))?.to_string();
|
|
s.edit(|d, _| {
|
|
let i = d.variable(&name).ok_or_else(|| bad("variables.delete", format!("no variable `{name}`")))?;
|
|
// Replace instances with their value (first page context), then shift higher indices down.
|
|
let value = d.variable_value(i, Some(0)).unwrap_or_default();
|
|
let n = d.text_variables.len();
|
|
let ids: Vec<_> = d.stories.keys().copied().collect();
|
|
for sid in ids {
|
|
let Some(st) = d.story(sid) else { continue };
|
|
if !st.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some_and(|k| k >= i)) {
|
|
continue;
|
|
}
|
|
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
|
let target = var_char(i).unwrap_or(' ').to_string();
|
|
while let Some(pos) = st.text.find(&target) {
|
|
st.replace(pos..pos + target.len(), &value);
|
|
}
|
|
for k in i + 1..n {
|
|
let (from, to) = (var_char(k).unwrap_or(' ').to_string(), var_char(k - 1).unwrap_or(' ').to_string());
|
|
while let Some(pos) = st.text.find(&from) {
|
|
st.replace(pos..pos + from.len(), &to);
|
|
}
|
|
}
|
|
}
|
|
d.text_variables.remove(i);
|
|
Ok(Value::Null)
|
|
})
|
|
}
|
|
),
|
|
cmd!(
|
|
"variables.insert",
|
|
"Insert Variable",
|
|
["Type", "Text Variables"],
|
|
None,
|
|
"{name} — at the text insertion point",
|
|
super::has_text,
|
|
|s, p| {
|
|
let name = str_param(p, "name").ok_or_else(|| bad("variables.insert", "missing name"))?;
|
|
let i = s.doc()?.doc.variable(name).ok_or_else(|| bad("variables.insert", format!("no variable `{name}`")))?;
|
|
let c = var_char(i).ok_or_else(|| bad("variables.insert", "too many variables"))?;
|
|
s.execute("text.insert", &json!({"text": c.to_string()}))
|
|
}
|
|
),
|
|
]
|
|
}
|
|
|
|
fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string();
|
|
let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string();
|
|
let kind = match str_param(p, "type").unwrap_or("custom") {
|
|
"custom" => VarKind::Custom { text: str_param(p, "text").unwrap_or("").into() },
|
|
"lastPageNumber" => VarKind::LastPageNumber { section: p.get("section").and_then(Value::as_bool).unwrap_or(false) },
|
|
"chapterNumber" => VarKind::ChapterNumber,
|
|
"fileName" => VarKind::FileName { extension: p.get("extension").and_then(Value::as_bool).unwrap_or(false) },
|
|
"creationDate" => VarKind::CreationDate { format: fmt() },
|
|
"modificationDate" => VarKind::ModificationDate { format: fmt() },
|
|
"outputDate" => VarKind::OutputDate { format: fmt() },
|
|
"runningHeader" => VarKind::RunningHeader {
|
|
style: str_param(p, "style").ok_or_else(|| bad("variables.define", "runningHeader needs `style`"))?.into(),
|
|
use_: if str_param(p, "use") == Some("lastOnPage") { designcraft_doc::vars::Use::LastOnPage } else { Default::default() },
|
|
character: p.get("character").and_then(Value::as_bool).unwrap_or(false),
|
|
},
|
|
t => return Err(bad("variables.define", format!("unknown type `{t}`"))),
|
|
};
|
|
let mut v = TextVariable::new(&name, kind);
|
|
v.before = str_param(p, "before").unwrap_or("").into();
|
|
v.after = str_param(p, "after").unwrap_or("").into();
|
|
s.edit(|d, _| {
|
|
let i = match d.variable(&name) {
|
|
Some(i) => {
|
|
d.text_variables[i] = v.clone();
|
|
i
|
|
}
|
|
None => {
|
|
if d.text_variables.len() >= designcraft_doc::vars::VAR_MAX {
|
|
return Err(bad("variables.define", "too many variables"));
|
|
}
|
|
d.text_variables.push(v.clone());
|
|
d.text_variables.len() - 1
|
|
}
|
|
};
|
|
Ok(json!({"index": i}))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A parent-page page-number marker follows Numbering & Section Options: changing a section's
|
|
/// start number renames its pages without changing their index or the number of sections, and
|
|
/// the marker composed for a page shows the new name.
|
|
#[test]
|
|
fn parent_page_number_follows_section_renumbering() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
|
|
let marker = designcraft_doc::story::PAGE_NUMBER.to_string();
|
|
let r = s
|
|
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker}))
|
|
.unwrap();
|
|
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
|
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker);
|
|
let shown = |s: &Session, page: usize| -> usize {
|
|
let d = s.doc().unwrap().doc.clone();
|
|
let cs = s.cache.get(&d, sid, Some(page));
|
|
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
|
};
|
|
assert_eq!(shown(&s, 2), 1, "page 3");
|
|
s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.page_name(2), "102");
|
|
assert_eq!(shown(&s, 2), 3, "page 102 after renumbering");
|
|
}
|
|
|
|
/// A section marker character shows the marker text of the section of the page it is composed
|
|
/// for: on each document page a parent-page frame is shown on, and in a frame on a document
|
|
/// page. A section without marker text shows nothing; the parent page itself shows "Section".
|
|
#[test]
|
|
fn section_marker_shows_the_pages_section_marker() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap();
|
|
s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap();
|
|
let marker = designcraft_doc::story::SECTION_MARKER.to_string();
|
|
let r = s
|
|
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker}))
|
|
.unwrap();
|
|
let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
|
let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0;
|
|
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap();
|
|
let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
|
let shown = |s: &Session, sid, page: Option<usize>| -> usize {
|
|
let d = s.doc().unwrap().doc.clone();
|
|
let cs = s.cache.get(&d, sid, page);
|
|
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
|
};
|
|
assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker");
|
|
assert_eq!(shown(&s, parent, Some(2)), "Intro".len());
|
|
assert_eq!(shown(&s, parent, Some(3)), "Intro".len());
|
|
assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page");
|
|
assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself");
|
|
// Editing the marker recomposes.
|
|
s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap();
|
|
assert_eq!(shown(&s, parent, Some(2)), "Appendix".len());
|
|
assert_eq!(shown(&s, own, None), "Appendix".len());
|
|
}
|
|
|
|
/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running
|
|
/// header is composed for the page it is shown on, not the first page with that name.
|
|
#[test]
|
|
fn running_header_on_parent_page_with_repeated_page_numbers() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap();
|
|
s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap();
|
|
let names: Vec<String> = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect();
|
|
assert_eq!(names, ["1", "2", "1", "2"]);
|
|
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
|
|
// The only heading is on the third page (the second section's page 1).
|
|
let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0;
|
|
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap();
|
|
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
|
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap();
|
|
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
|
|
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
|
|
let r = s
|
|
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""}))
|
|
.unwrap();
|
|
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
|
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
|
s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap();
|
|
let d = s.doc().unwrap().doc.clone();
|
|
let shown = |page: usize| -> usize {
|
|
let cs = s.cache.get(&d, sid, Some(page));
|
|
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
|
};
|
|
assert_eq!(shown(0), 0, "no heading on or before the first page");
|
|
assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading");
|
|
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
|
|
}
|
|
|
|
#[test]
|
|
fn running_header_on_parent_page_follows_headings() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
|
|
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
|
|
// Headings on pages 1 and 3 (page 2 carries page 1's).
|
|
for (page, text) in [(0, "Alpha"), (2, "Epsilon")] {
|
|
let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0;
|
|
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": text})).unwrap();
|
|
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
|
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap();
|
|
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
|
|
}
|
|
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
|
|
s.execute("variables.define", &json!({"name": "Total", "type": "custom", "text": "of "})).unwrap();
|
|
// A header frame on the parent with "<running header> · <page> / <last page>".
|
|
let r = s
|
|
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""}))
|
|
.unwrap();
|
|
let fid = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
|
let sid = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
|
|
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
|
s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap();
|
|
s.execute("text.insert", &json!({"text": " · "})).unwrap();
|
|
s.execute("variables.insert", &json!({"name": "Last Page Number"})).unwrap();
|
|
let d = s.doc().unwrap().doc.clone();
|
|
assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)));
|
|
// Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3".
|
|
let shown = |page: usize| -> usize {
|
|
let cs = s.cache.get(&d, sid, Some(page));
|
|
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
|
};
|
|
let (p1, p2, p3) = (shown(0), shown(1), shown(2));
|
|
assert_eq!(p1, p2, "page 2 carries page 1's header");
|
|
assert_eq!(p3, p1 + 2, "Epsilon is two letters longer than Alpha");
|
|
let idx = s.cache.running_index(&d);
|
|
assert_eq!(idx.value("Heading", false, Default::default(), 0), "Alpha");
|
|
assert_eq!(idx.value("Heading", false, Default::default(), 1), "Alpha");
|
|
assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon");
|
|
let list = s.execute("variables.list", &json!({})).unwrap();
|
|
assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3"));
|
|
// Deleting a variable flattens its instances to text.
|
|
s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap();
|
|
assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3"));
|
|
}
|
|
}
|