mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
egui037: Port DesignCraft to egui 0.37
Pin the final egui, winit and shared UI revisions and reconcile current main. Preserve document-epoch input and screenshot boundaries, host action ordering, and Windows path regression coverage. Add click/End batch characterization. Validation: all nine Linux CI stages pass (3230 tests, zero failures); Windows CI and bounded native editing workflows pass on the same production source.
This commit is contained in:
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Anchored Object…",
|
||||
["Object", "Anchored Object"],
|
||||
None,
|
||||
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
|
||||
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text",
|
||||
has_doc,
|
||||
insert
|
||||
),
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Anchored Object Options…",
|
||||
["Object", "Anchored Object"],
|
||||
None,
|
||||
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
|
||||
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}",
|
||||
has_doc,
|
||||
options
|
||||
),
|
||||
@@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
|
||||
}
|
||||
("custom", b) => {
|
||||
use designcraft_doc::anchored::AnchorRelative as R;
|
||||
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
|
||||
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
|
||||
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
|
||||
}
|
||||
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
|
||||
let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b {
|
||||
AnchorPosition::Custom {
|
||||
x_relative,
|
||||
y_relative,
|
||||
x_offset,
|
||||
y_offset,
|
||||
object_point,
|
||||
ref_point,
|
||||
keep_within_column,
|
||||
spine_relative,
|
||||
} => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative),
|
||||
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false),
|
||||
};
|
||||
let rel = |k: &str, d: R| -> Result<R> {
|
||||
match p.get(k) {
|
||||
@@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
|
||||
object_point: pt("objectPoint", op0),
|
||||
ref_point: pt("refPoint", rp0),
|
||||
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
|
||||
spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0),
|
||||
}
|
||||
}
|
||||
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
|
||||
@@ -316,5 +324,12 @@ mod custom_tests {
|
||||
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
|
||||
"IDML Anchored position"
|
||||
);
|
||||
// Relative to Spine keeps the other custom settings.
|
||||
s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert!(matches!(
|
||||
st.objects[0].position,
|
||||
designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ impl ChapterBatch {
|
||||
fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> {
|
||||
use std::path::Path;
|
||||
|
||||
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
|
||||
if Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
|
||||
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
|
||||
}
|
||||
let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
|
||||
@@ -254,7 +254,7 @@ fn find_markers(text: &str) -> Vec<(usize, usize, String)> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
|
||||
i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1));
|
||||
i = i.saturating_add(text[i..].chars().next().map(char::len_utf8).unwrap_or(1));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ fn sniff(name: &str, text: &str) -> Delimiter {
|
||||
}
|
||||
|
||||
pub fn extension(name: &str) -> String {
|
||||
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
struct HeaderDelimiterCandidate {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus};
|
||||
use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, Selection, SourceStatus, StoryId, TextSel};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::parse::{self, parse_csv};
|
||||
@@ -790,6 +790,30 @@ fn tiling_rows_first_bottom_inset_and_refusals() {
|
||||
assert_eq!(s.documents().len(), before);
|
||||
}
|
||||
|
||||
/// An IME delete-surrounding while a record is previewed edits the template, not the filled
|
||||
/// record, and its undo step restores the template.
|
||||
#[test]
|
||||
fn an_ime_delete_ends_a_preview_and_edits_the_template() {
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
let f = text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
|
||||
let sid = StoryId(f["story"].as_u64().unwrap());
|
||||
select_csv(&mut s, "Name\nAda\nGrace\n");
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
|
||||
assert_eq!(texts(&s), vec!["<<Name>>".to_string()]);
|
||||
exec(&mut s, "data.preview", json!({"record": 2}));
|
||||
assert_eq!(texts(&s), vec!["Grace".to_string()], "the preview shows the record");
|
||||
// The caret was placed in the preview, at the end of the filled text.
|
||||
s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: 5, focus: 5, frame: None, cell: None });
|
||||
let m = s.ime_delete_surrounding(1, 0, false).unwrap().expect("the delete changed the template");
|
||||
assert!(m.inserted.is_empty());
|
||||
assert!(s.doc().unwrap().preview_stash.is_none(), "the preview ended before the edit");
|
||||
assert_eq!(texts(&s), vec!["<<Nae>>".to_string()], "the template lost the character, not the filled record");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(texts(&s), vec!["<<Name>>".to_string()], "Undo restores the template, never the preview record");
|
||||
assert!(s.doc().unwrap().preview_stash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_remove_preview_inline_rows_and_old_files() {
|
||||
let dir = scratch("update");
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId};
|
||||
use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
|
||||
use crate::Result;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -37,6 +37,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"story": ts.story.0, "id": id}))
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"note.convertFromText",
|
||||
"Convert to Note",
|
||||
["Type", "Notes"],
|
||||
None,
|
||||
"{author?} → {story, id}. The selected text moves into a new note anchored where it was.",
|
||||
has_text_range,
|
||||
convert_from_text
|
||||
),
|
||||
cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| {
|
||||
let (sid, id) = target(p)?;
|
||||
let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
|
||||
@@ -93,9 +102,76 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}),
|
||||
cmd!(
|
||||
noundo "note.previous",
|
||||
"Previous Note",
|
||||
["Type", "Notes"],
|
||||
None,
|
||||
"{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.",
|
||||
has_previous,
|
||||
|s, _| go_to(s, false)
|
||||
),
|
||||
cmd!(
|
||||
noundo "note.next",
|
||||
"Next Note",
|
||||
["Type", "Notes"],
|
||||
None,
|
||||
"{} → {story, id, pos}. The caret moves after the anchor of the story's next note.",
|
||||
has_next,
|
||||
|s, _| go_to(s, true)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Characters whose payload lives outside the story text: a note made from text that holds one
|
||||
/// would lose it.
|
||||
const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR];
|
||||
|
||||
fn has_text_range(s: &Session) -> std::result::Result<(), String> {
|
||||
has_text(s)?;
|
||||
match s.active().and_then(|d| d.selection.text) {
|
||||
Some(t) if t.cell.is_some() => Err("notes go in story text".into()),
|
||||
Some(t) if t.is_caret() => Err("select the text to convert".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a
|
||||
/// new note, whose anchor takes the text's place.
|
||||
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "note.convertFromText";
|
||||
let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?;
|
||||
if ts.cell.is_some() {
|
||||
return Err(bad(ID, "notes go in story text"));
|
||||
}
|
||||
let r = ts.range();
|
||||
if r.is_empty() {
|
||||
return Err(bad(ID, "select the text to convert"));
|
||||
}
|
||||
let author = str_param(p, "author").unwrap_or("").to_string();
|
||||
s.edit(|d, sel| {
|
||||
let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?;
|
||||
let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string();
|
||||
if text.contains(MARKS) {
|
||||
return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker"));
|
||||
}
|
||||
let fmt = story.format_after(r.start).clone();
|
||||
story.delete(r.clone());
|
||||
story.insert_with(r.start, &NOTE_MARK.to_string(), fmt);
|
||||
story.fix_marks();
|
||||
let k = story.text[..r.start].matches(NOTE_MARK).count();
|
||||
let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?;
|
||||
let n = Arc::make_mut(n);
|
||||
(n.text, n.author) = (text, author);
|
||||
let id = n.id;
|
||||
if let Some(t) = &mut sel.text {
|
||||
let c = r.start + NOTE_MARK.len_utf8();
|
||||
(t.anchor, t.focus) = (c, c);
|
||||
}
|
||||
Ok(json!({"story": ts.story.0, "id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn target(p: &Value) -> Result<(StoryId, u64)> {
|
||||
let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?;
|
||||
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?;
|
||||
@@ -108,6 +184,53 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
|
||||
st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The anchor offset and id of the note before or after the selection `t` in its story. Going
|
||||
/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note.
|
||||
fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> {
|
||||
let r = t.range();
|
||||
let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i));
|
||||
let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?;
|
||||
Some((at, st.editorial.get(k)?.id))
|
||||
}
|
||||
|
||||
fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> {
|
||||
has_text(s)?;
|
||||
match s.active().and_then(|d| Some((d, d.selection.text?))) {
|
||||
Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()),
|
||||
Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()),
|
||||
_ => Err(no_note(forward).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_previous(s: &crate::Session) -> std::result::Result<(), String> {
|
||||
has_note(s, false)
|
||||
}
|
||||
|
||||
fn has_next(s: &crate::Session) -> std::result::Result<(), String> {
|
||||
has_note(s, true)
|
||||
}
|
||||
|
||||
fn no_note(forward: bool) -> &'static str {
|
||||
if forward { "no later note in this story" } else { "no earlier note in this story" }
|
||||
}
|
||||
|
||||
/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after
|
||||
/// the anchor of the story's previous or next note.
|
||||
fn go_to(s: &mut crate::Session, forward: bool) -> Result<Value> {
|
||||
let id = if forward { "note.next" } else { "note.previous" };
|
||||
let st = s.doc_mut()?;
|
||||
let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad(id, "notes go in story text"));
|
||||
}
|
||||
let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?;
|
||||
let pos = at + NOTE_MARK.len_utf8();
|
||||
st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos));
|
||||
st.bump_revision();
|
||||
s.typing_format = None;
|
||||
Ok(json!({"story": t.story.0, "id": note, "pos": pos}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -136,6 +259,46 @@ mod tests {
|
||||
assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_and_next_note_step_through_the_story_s_notes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two three."})).unwrap();
|
||||
let len = designcraft_doc::NOTE_MARK.len_utf8();
|
||||
let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap();
|
||||
caret(&mut s, 3);
|
||||
let first = s.execute("note.new", &json!({"text": "a"})).unwrap();
|
||||
caret(&mut s, 7 + len);
|
||||
let second = s.execute("note.new", &json!({"text": "b"})).unwrap();
|
||||
caret(&mut s, 0);
|
||||
let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..)));
|
||||
assert!(disabled(&mut s, "note.previous"), "no note before the start");
|
||||
let n = s.execute("note.next", &json!({})).unwrap();
|
||||
assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len)));
|
||||
let n = s.execute("note.next", &json!({})).unwrap();
|
||||
assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len)));
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor");
|
||||
assert!(disabled(&mut s, "note.next"), "no note after the last");
|
||||
// From just after the second anchor, Previous Note goes to the first one.
|
||||
let n = s.execute("note.previous", &json!({})).unwrap();
|
||||
assert_eq!(n["id"], first["id"]);
|
||||
assert!(disabled(&mut s, "note.previous"));
|
||||
// From the middle of the story, each direction finds its neighbor.
|
||||
caret(&mut s, 5 + len);
|
||||
assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]);
|
||||
caret(&mut s, 5 + len);
|
||||
assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]);
|
||||
// Formatting set at the insertion point does not survive the move.
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
assert!(s.typing_format.is_some());
|
||||
s.execute("note.previous", &json!({})).unwrap();
|
||||
assert!(s.typing_format.is_none(), "note.previous");
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
s.execute("note.next", &json!({})).unwrap();
|
||||
assert!(s.typing_format.is_none(), "note.next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_insert_into_a_story_whose_markers_lack_payloads() {
|
||||
let mut s = Session::new();
|
||||
@@ -159,4 +322,59 @@ mod tests {
|
||||
assert_eq!(st.endnotes[1].text.text, "An endnote.");
|
||||
assert_eq!(st.notes[1].text.text, "A footnote.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_text_converts_to_a_note_at_its_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
let mark = designcraft_doc::NOTE_MARK.len_utf8();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
|
||||
s.execute("note.new", &json!({"text": "earlier"})).unwrap();
|
||||
// "two\nthree " across the paragraph return, selected backwards.
|
||||
let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len());
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap();
|
||||
let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap();
|
||||
let st = &s.doc().unwrap().doc.stories[&sid];
|
||||
st.check().unwrap();
|
||||
assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK));
|
||||
assert_eq!(st.paras.len(), 1, "the paragraph return went into the note");
|
||||
let list = s.execute("note.list", &json!({})).unwrap();
|
||||
assert_eq!(list[0]["text"], "earlier");
|
||||
assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a)));
|
||||
assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed")));
|
||||
let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus));
|
||||
assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor");
|
||||
s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = &s.doc().unwrap().doc.stories[&sid];
|
||||
assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_note_needs_selected_story_text_without_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap();
|
||||
let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..)));
|
||||
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text");
|
||||
s.execute("note.new", &json!({"text": "keep"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.stories[&sid].clone();
|
||||
let all = before.len();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap();
|
||||
let refused = s.execute("note.convertFromText", &json!({}));
|
||||
assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost");
|
||||
assert_eq!(s.doc().unwrap().doc.stories[&sid], before);
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret()));
|
||||
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) {
|
||||
let first = d.endnote_start(st.id);
|
||||
for (k, n) in st.endnotes.iter().enumerate() {
|
||||
out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text}));
|
||||
out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text}));
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v["number"].as_u64());
|
||||
|
||||
@@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
/// the file opens from the file manager. A path without a file name is left for the write to fail.
|
||||
pub fn with_document_extension(path: &str) -> String {
|
||||
let p = std::path::Path::new(path);
|
||||
let ext = p.extension().and_then(|e| e.to_str());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str);
|
||||
if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) {
|
||||
return path.to_string();
|
||||
}
|
||||
@@ -231,11 +231,24 @@ fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
|
||||
Ok(nd)
|
||||
}
|
||||
|
||||
/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a
|
||||
/// paragraph's last word or across a column. Opened and imported files keep their own values,
|
||||
/// which is why `ParaProps` keeps the IDML defaults (on).
|
||||
fn new_document(nd: &NewDocument) -> Document {
|
||||
let mut d = Document::new(nd);
|
||||
if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) {
|
||||
st.para.hyph_capitalized = Some(false);
|
||||
st.para.hyph_last_word = Some(false);
|
||||
st.para.hyph_across_column = Some(false);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut nd = new_document_settings(s, p)?;
|
||||
s.untitled += 1;
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
|
||||
let d = Document::new(&nd);
|
||||
let d = new_document(&nd);
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
@@ -259,7 +272,7 @@ impl Session {
|
||||
self.untitled += 1;
|
||||
}
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
|
||||
let st = DocState::new(Document::new(&nd), None);
|
||||
let st = DocState::new(new_document(&nd), None);
|
||||
let uid = st.uid;
|
||||
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
|
||||
Some((i, d)) => {
|
||||
@@ -469,7 +482,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
// The content decides first: a file's extension may be missing or wrong. The extension
|
||||
// then routes files whose content isn't recognised to the reader that explains why.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
|
||||
let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{");
|
||||
if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) {
|
||||
return super::interchange::open_idml(s, p);
|
||||
@@ -2124,3 +2137,35 @@ mod recent_tests {
|
||||
assert!(old.recent_files.is_empty(), "older prefs.json without the list still load");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hyphenation_default_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::BASIC_PARAGRAPH;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) {
|
||||
let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default());
|
||||
(pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false));
|
||||
|
||||
let mut d = designcraft_doc::Document::new(&NewDocument::default());
|
||||
let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap();
|
||||
st.para.hyph_capitalized = Some(true);
|
||||
st.para.hyph_last_word = Some(true);
|
||||
st.para.hyph_across_column = Some(true);
|
||||
let b64 = super::base64_encode(&designcraft_idml::export_idml(&d));
|
||||
s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap();
|
||||
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,44 +82,49 @@ pub(crate) fn sync(d: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
|
||||
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
|
||||
fn detect(bounds: &[Rect]) -> FlexLayout {
|
||||
let tol = 0.5;
|
||||
let in_line = |row: bool| {
|
||||
let mut v: Vec<&Rect> = bounds.iter().collect();
|
||||
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
|
||||
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
|
||||
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
|
||||
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
|
||||
};
|
||||
fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option<f64> {
|
||||
let mut v: Vec<&Rect> = bounds.iter().collect();
|
||||
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
|
||||
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
|
||||
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
|
||||
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
|
||||
}
|
||||
|
||||
fn detect_align(bounds: &[Rect], row: bool, tol: f64) -> FlexAlign {
|
||||
let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol);
|
||||
let align = |row: bool| {
|
||||
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
|
||||
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
|
||||
if same(a) {
|
||||
FlexAlign::Start
|
||||
} else if same(c) {
|
||||
FlexAlign::Center
|
||||
} else if same(e) {
|
||||
FlexAlign::End
|
||||
} else {
|
||||
FlexAlign::Start
|
||||
}
|
||||
};
|
||||
let auto = |gap: f64, row: bool| FlexLayout {
|
||||
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
|
||||
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
|
||||
if same(a) {
|
||||
FlexAlign::Start
|
||||
} else if same(c) {
|
||||
FlexAlign::Center
|
||||
} else if same(e) {
|
||||
FlexAlign::End
|
||||
} else {
|
||||
FlexAlign::Start
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_flex(gap: f64, row: bool, align: FlexAlign) -> FlexLayout {
|
||||
FlexLayout {
|
||||
direction: if row { FlexDirection::Row } else { FlexDirection::Column },
|
||||
justify: FlexJustify::SpaceBetween,
|
||||
align: align(row),
|
||||
align,
|
||||
width: FlexSize::Auto,
|
||||
height: FlexSize::Auto,
|
||||
gap_h: if row { gap.max(0.0) } else { 0.0 },
|
||||
gap_v: if row { 0.0 } else { gap.max(0.0) },
|
||||
..Default::default()
|
||||
};
|
||||
match (in_line(true), in_line(false)) {
|
||||
(Some(gap), _) => auto(gap, true),
|
||||
(None, Some(gap)) => auto(gap, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
|
||||
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
|
||||
fn detect(bounds: &[Rect]) -> FlexLayout {
|
||||
let tol = 0.5;
|
||||
match (in_line_gap(bounds, true, tol), in_line_gap(bounds, false, tol)) {
|
||||
(Some(gap), _) => auto_flex(gap, true, detect_align(bounds, true, tol)),
|
||||
(None, Some(gap)) => auto_flex(gap, false, detect_align(bounds, false, tol)),
|
||||
_ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +512,10 @@ mod tests {
|
||||
let (idml, _) = linked_idml(link, false).unwrap();
|
||||
let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap());
|
||||
// Beside the package, and the link itself where it is on this computer.
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}");
|
||||
// A rooted local path such as /dev/zero acquires the package's drive on Windows.
|
||||
let local = std::path::Path::new(link);
|
||||
let external = (local.has_root() && !link.starts_with(r"\\")).then(|| std::path::absolute(dir.join(local)).unwrap());
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir) || Some(p) == external.as_ref()), "{link}: {seen:?}");
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert!(asset.data.is_empty(), "{link}");
|
||||
assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
|
||||
let Some(link) = &a.link else { return false };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path))
|
||||
!crate::io::document_path(link, document).is_ok_and(crate::io::exists)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes.
|
||||
///
|
||||
/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the
|
||||
/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An
|
||||
/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is
|
||||
/// the one packaging already applies to a linked file, and a file over it is refused by name
|
||||
/// rather than read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reload_capped(path: impl AsRef<Path>, cap: u64) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
let path = path.as_ref();
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
if b.len() as u64 > cap {
|
||||
return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024))));
|
||||
}
|
||||
if designcraft_render::is_pdf(&b) {
|
||||
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
|
||||
}
|
||||
@@ -480,7 +495,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
|
||||
let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into());
|
||||
let mut name = old.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| a.name.clone().into());
|
||||
if let Some(e) = &ext {
|
||||
name = std::path::Path::new(&name).with_extension(e).into_os_string();
|
||||
}
|
||||
@@ -490,8 +505,18 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(dir) => Some(dir.clone()),
|
||||
None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
|
||||
};
|
||||
let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n));
|
||||
let cand = folder.filter(|_| plain_name).map(|f| f.join(&name));
|
||||
let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\']));
|
||||
// A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`,
|
||||
// a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b`
|
||||
// on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name
|
||||
// itself, directly in the folder as the file system resolves it, counts. The link keeps
|
||||
// the folder as given.
|
||||
let cand = folder.zip(plain_name).and_then(|(f, n)| {
|
||||
let c = crate::io::folder_path(n, &f).ok()?;
|
||||
let real = |p: &Path| crate::io::canonicalize(p).ok();
|
||||
let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p));
|
||||
(c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c)
|
||||
});
|
||||
if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
|
||||
todo.push((a.id, cand));
|
||||
} else {
|
||||
@@ -574,6 +599,31 @@ mod tests {
|
||||
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
|
||||
}
|
||||
|
||||
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_linked_file_over_the_cap_is_refused_instead_of_read() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.png");
|
||||
let bytes = png(40, 40);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
|
||||
// Under the cap the file is read and measured as before.
|
||||
let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read");
|
||||
assert_eq!((read.len(), px), (bytes.len(), (40, 40)));
|
||||
|
||||
// Over it the file is refused by name, and the error says what the limit is.
|
||||
let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}");
|
||||
|
||||
// The real cap is the one packaging uses, so an ordinary graphic still loads.
|
||||
assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn pdf(sizes: &[(u32, u32)]) -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap();
|
||||
@@ -808,7 +858,7 @@ mod tests {
|
||||
assert_eq!(a.pixels, Some((60, 20)));
|
||||
assert_eq!(a.mime, "application/postscript");
|
||||
assert_eq!(a.data.as_slice(), eps(30).as_bytes());
|
||||
let moved = dir.join("new/replacement.eps");
|
||||
let moved = dir.join("new").join("replacement.eps");
|
||||
std::fs::rename(&replacement, &moved).unwrap();
|
||||
assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1);
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str());
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from
|
||||
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
|
||||
|
||||
use designcraft_doc::NumberedList;
|
||||
use designcraft_doc::{ListType, NumberedList};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's
|
||||
/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin,
|
||||
/// the text after its tab at the left indent.
|
||||
pub const LIST_HANG: f64 = 18.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"list.bullets",
|
||||
"Apply Bullets",
|
||||
["Type", "Bulleted & Numbered Lists"],
|
||||
None,
|
||||
"{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied",
|
||||
has_text_or_frames,
|
||||
|s, p| apply_list(s, p, ListType::Bullets)
|
||||
),
|
||||
cmd!(
|
||||
"list.numbers",
|
||||
"Apply Numbers",
|
||||
["Type", "Bulleted & Numbered Lists"],
|
||||
None,
|
||||
"{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets",
|
||||
has_text_or_frames,
|
||||
|s, p| apply_list(s, p, ListType::Numbers)
|
||||
),
|
||||
cmd!(
|
||||
"list.define",
|
||||
"Define Lists…",
|
||||
@@ -48,12 +72,181 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and
|
||||
/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the
|
||||
/// style rather than set locally, so a paragraph back to its style shows no override.
|
||||
fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result<Value> {
|
||||
let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" };
|
||||
let on = match p.get("on") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?),
|
||||
};
|
||||
let targets = super::text::format_targets_pub(s);
|
||||
if targets.is_empty() {
|
||||
return Err(bad(name, "select text or a text frame"));
|
||||
}
|
||||
// Off when every selected paragraph already has this list, as the button shows it pressed.
|
||||
let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)));
|
||||
s.edit(|d, _| {
|
||||
let styles = d.styles.clone();
|
||||
for t in &targets {
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
let span = para_span(&st.text, &t.range);
|
||||
st.format_paras(span, |pf| {
|
||||
let (now, _) = styles.resolve_para(pf);
|
||||
// Off takes away this list only: a paragraph with the other list keeps it.
|
||||
if !on && now.list_type != kind {
|
||||
return;
|
||||
}
|
||||
let (style, _) = styles.resolve_para_style(&pf.style);
|
||||
// The hanging indent this button set (and nothing has changed since).
|
||||
let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG;
|
||||
// A value: left to the style when it gives the same, else set here.
|
||||
let mut set = |key: &str, v: Value, same: bool| {
|
||||
let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
|
||||
};
|
||||
let list = if on { kind } else { ListType::None };
|
||||
set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list);
|
||||
if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
|
||||
set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
|
||||
set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
|
||||
set("listHang", json!(true), false);
|
||||
} else if !on && now.list_type == kind {
|
||||
// Only the hanging indent set here: one the paragraph had before, or its style
|
||||
// gives, stays.
|
||||
if own_hang {
|
||||
set("leftIndent", json!(0.0), style.left_indent == 0.0);
|
||||
set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
|
||||
}
|
||||
set("listHang", Value::Null, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({"on": on}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after
|
||||
/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final
|
||||
/// return, stays in when the range runs to the end (a whole frame).
|
||||
fn para_span(text: &str, range: &std::ops::Range<usize>) -> std::ops::Range<usize> {
|
||||
let end = range.end.max(range.start);
|
||||
if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end }
|
||||
}
|
||||
|
||||
/// Whether every paragraph of `targets` has list `kind`.
|
||||
fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool {
|
||||
targets.iter().all(|t| {
|
||||
d.text_story(t.story, t.cell).is_some_and(|st| {
|
||||
let span = para_span(&st.text, &t.range);
|
||||
let (a, b) = (st.para_at(span.start), st.para_at(span.end));
|
||||
st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows
|
||||
/// pressed: every paragraph the list commands apply to has list `kind`, through the selected
|
||||
/// text, table cells or text frames.
|
||||
pub fn selection_has_list(&self, kind: ListType) -> bool {
|
||||
let targets = super::text::format_targets_pub(self);
|
||||
!targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the
|
||||
/// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the
|
||||
/// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them.
|
||||
#[test]
|
||||
fn list_buttons_apply_the_default_hanging_indent() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
|
||||
let sid = f["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap();
|
||||
let para = |s: &Session, i: usize| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
|
||||
(d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone())
|
||||
};
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
|
||||
for i in 0..2 {
|
||||
let (p, _) = para(&s, i);
|
||||
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0));
|
||||
}
|
||||
assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// The number of a bullet's tab: the text starts at the left indent.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap();
|
||||
assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0);
|
||||
// Numbers instead: the indent stays.
|
||||
s.execute("list.numbers", &json!({})).unwrap();
|
||||
let (p, _) = para(&s, 0);
|
||||
assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0));
|
||||
// Numbers off: list and hanging indent go, and nothing is left set on the paragraph.
|
||||
assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false);
|
||||
let (p, own) = para(&s, 0);
|
||||
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0));
|
||||
assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style");
|
||||
// A paragraph with its own indents keeps them.
|
||||
s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap();
|
||||
s.execute("list.bullets", &json!({"on": true})).unwrap();
|
||||
let (p, _) = para(&s, 0);
|
||||
assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0));
|
||||
s.execute("list.bullets", &json!({"on": false})).unwrap();
|
||||
assert_eq!(para(&s, 0).0.left_indent, 36.0);
|
||||
assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
|
||||
}
|
||||
|
||||
/// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted
|
||||
/// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own
|
||||
/// list only: a bulleted paragraph keeps bullets and indent when Numbers go off.
|
||||
#[test]
|
||||
fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() {
|
||||
use designcraft_doc::ListType;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
|
||||
let sid = f["story"].as_u64().unwrap();
|
||||
let select = |s: &mut Session, a: usize, b: usize| {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
|
||||
};
|
||||
select(&mut s, 0, 3);
|
||||
s.execute("list.bullets", &json!({})).unwrap();
|
||||
assert!(s.selection_has_list(ListType::Bullets));
|
||||
assert!(!s.selection_has_list(ListType::Numbers));
|
||||
// From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both.
|
||||
select(&mut s, 0, 7);
|
||||
assert!(!s.selection_has_list(ListType::Bullets));
|
||||
// The frame selected: its whole story, Flour unbulleted.
|
||||
s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap();
|
||||
assert!(!s.selection_has_list(ListType::Bullets));
|
||||
// Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent.
|
||||
select(&mut s, 5, 7);
|
||||
s.execute("list.numbers", &json!({"on": true})).unwrap();
|
||||
select(&mut s, 0, 7);
|
||||
s.execute("list.numbers", &json!({"on": false})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
|
||||
let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0;
|
||||
assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0));
|
||||
assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0));
|
||||
// With no text or frame selected, nothing is pressed.
|
||||
s.execute("selection.set", &json!({"ids": []})).unwrap();
|
||||
assert!(!s.selection_has_list(ListType::Bullets));
|
||||
}
|
||||
|
||||
/// The digit the story's first line starts with (its list number).
|
||||
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
|
||||
let face = g.face.get();
|
||||
@@ -99,4 +292,74 @@ mod tests {
|
||||
s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap();
|
||||
assert_eq!(first_char(&s, stories[1]), '4');
|
||||
}
|
||||
|
||||
/// A selection ending right after a return (the whole first paragraph, as a triple click selects
|
||||
/// it) applies the list to that paragraph only, and the button shows it pressed.
|
||||
#[test]
|
||||
fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() {
|
||||
use designcraft_doc::ListType;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s
|
||||
.execute(
|
||||
"frame.create",
|
||||
&json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs
|
||||
Milk
|
||||
Flour
|
||||
"}),
|
||||
)
|
||||
.unwrap();
|
||||
let sid = f["story"].as_u64().unwrap();
|
||||
let kinds = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::<Vec<_>>()
|
||||
};
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
|
||||
assert!(!s.selection_has_list(ListType::Bullets));
|
||||
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
|
||||
assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]);
|
||||
assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected");
|
||||
// The whole frame still takes in its last, empty paragraph.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap();
|
||||
s.execute("list.numbers", &json!({"on": true})).unwrap();
|
||||
assert_eq!(kinds(&s), [ListType::Numbers; 4]);
|
||||
}
|
||||
|
||||
/// A paragraph's own hanging indent, the same as the button's, stays when the list goes off:
|
||||
/// the button takes away only the indent it set.
|
||||
#[test]
|
||||
fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
|
||||
let sid = f["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
|
||||
s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
|
||||
s.execute("list.bullets", &json!({"on": true})).unwrap();
|
||||
s.execute("list.bullets", &json!({"on": false})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
|
||||
let p = d.styles.resolve_para(pf).0;
|
||||
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
|
||||
assert_eq!(pf.para.list_hang, None, "nothing of the list left");
|
||||
}
|
||||
|
||||
/// A hanging indent the paragraph style gives stays when the list goes off.
|
||||
#[test]
|
||||
fn a_style_hanging_indent_stays_when_the_list_goes_off() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
|
||||
let sid = f["story"].as_u64().unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap();
|
||||
s.execute("list.bullets", &json!({"on": true})).unwrap();
|
||||
s.execute("list.bullets", &json!({"on": false})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
|
||||
let p = d.styles.resolve_para(pf).0;
|
||||
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
|
||||
assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,9 +201,9 @@ fn plan(doc: &Document, src: &Document, paragraph: Option<&[String]>, character:
|
||||
Ok((para, chars))
|
||||
}
|
||||
|
||||
fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, ID)?;
|
||||
let conflict = match p.get("conflicts") {
|
||||
/// The `conflicts` parameter: how a style that differs under the same name is handled.
|
||||
fn conflict_param(p: &Value) -> Result<Conflict> {
|
||||
Ok(match p.get("conflicts") {
|
||||
None | Some(Value::Null) => Conflict::Redefine,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("redefine") => Conflict::Redefine,
|
||||
@@ -211,7 +211,37 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("autoRename") => Conflict::AutoRename,
|
||||
_ => return Err(bad(ID, "`conflicts` must be redefine, useExisting or autoRename")),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Give the loaded style `ours` the export tag the source had for `theirs` (`kind` is `p` or `c`).
|
||||
fn carry_export_tag(src: &Styles, dst: &mut Styles, kind: char, theirs: &str, ours: &str) {
|
||||
let tag = format!("{kind}:{ours}");
|
||||
match src.export_tags.get(&format!("{kind}:{theirs}")) {
|
||||
Some(t) => dst.export_tags.insert(tag, t.clone()),
|
||||
None => dst.export_tags.remove(&tag),
|
||||
};
|
||||
}
|
||||
|
||||
/// A loaded style and one the document keeps can name each other as their base.
|
||||
fn check_based_on_cycles(styles: &Styles, loaded: &[(bool, String)]) -> Result<()> {
|
||||
for (is_para, name) in loaded {
|
||||
let looped = if *is_para {
|
||||
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
|
||||
} else {
|
||||
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
|
||||
};
|
||||
if looped {
|
||||
let kind = if *is_para { "paragraph" } else { "character" };
|
||||
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, ID)?;
|
||||
let conflict = conflict_param(p)?;
|
||||
let (paragraph, character) = (names(p, "paragraph")?, names(p, "character")?);
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let (mut para, mut chars) = plan(&doc, &src, paragraph.as_deref(), character.as_deref(), conflict)?;
|
||||
@@ -231,11 +261,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
p.nested_line_styles.iter_mut().flatten().for_each(|l| chars.follow(&mut l.style));
|
||||
swatches.extend(char_swatches(&style.chars).chain(para_swatches(&style.para)).cloned());
|
||||
lists.extend(style.para.list_name.iter().cloned());
|
||||
let tag = format!("p:{}", style.name);
|
||||
match src.styles.export_tags.get(&format!("p:{}", theirs.name)) {
|
||||
Some(t) => styles.export_tags.insert(tag, t.clone()),
|
||||
None => styles.export_tags.remove(&tag),
|
||||
};
|
||||
carry_export_tag(&src.styles, &mut styles, 'p', &theirs.name, &style.name);
|
||||
loaded.push((true, style.name.clone()));
|
||||
match styles.para_mut(&style.name) {
|
||||
Some(m) if action == Action::Replace => {
|
||||
@@ -256,11 +282,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars.follow(&mut style.name);
|
||||
style.based_on.iter_mut().for_each(|n| chars.follow(n));
|
||||
swatches.extend(char_swatches(&style.chars).cloned());
|
||||
let tag = format!("c:{}", style.name);
|
||||
match src.styles.export_tags.get(&format!("c:{}", theirs.name)) {
|
||||
Some(t) => styles.export_tags.insert(tag, t.clone()),
|
||||
None => styles.export_tags.remove(&tag),
|
||||
};
|
||||
carry_export_tag(&src.styles, &mut styles, 'c', &theirs.name, &style.name);
|
||||
loaded.push((false, style.name.clone()));
|
||||
match styles.char_style_mut(&style.name) {
|
||||
Some(m) if action == Action::Replace => {
|
||||
@@ -275,18 +297,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A loaded style and one the document keeps can name each other as their base.
|
||||
for (is_para, name) in &loaded {
|
||||
let looped = if *is_para {
|
||||
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
|
||||
} else {
|
||||
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
|
||||
};
|
||||
if looped {
|
||||
let kind = if *is_para { "paragraph" } else { "character" };
|
||||
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
|
||||
}
|
||||
}
|
||||
check_based_on_cycles(&styles, &loaded)?;
|
||||
|
||||
let swatches = missing_swatches(&doc, &src, swatches);
|
||||
let lists: Vec<NumberedList> = {
|
||||
|
||||
@@ -6,7 +6,7 @@ mod articles;
|
||||
pub mod book;
|
||||
mod buttons;
|
||||
pub(crate) mod captions;
|
||||
mod changes;
|
||||
pub(crate) mod changes;
|
||||
mod color_theme;
|
||||
mod colorsettings;
|
||||
mod conditions;
|
||||
|
||||
@@ -221,4 +221,15 @@ mod tests {
|
||||
// Caret is now in footnote text: no nested footnotes.
|
||||
assert!(s.execute("footnote.insert", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_start_at_does_not_overflow() {
|
||||
let (mut s, sid) = session_with_text();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
|
||||
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
|
||||
s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = s.cache.get(&d, sid, None);
|
||||
assert_eq!(cs.frames[0].notes.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effects",
|
||||
[],
|
||||
None,
|
||||
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step",
|
||||
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step",
|
||||
has_selection,
|
||||
effects_all
|
||||
),
|
||||
@@ -598,7 +598,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
@@ -2293,8 +2293,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?)
|
||||
}
|
||||
};
|
||||
// The document's global light angle, changed from an Angle field with Use Global Light on.
|
||||
let global_light = match p.get("globalLight") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?),
|
||||
};
|
||||
let ids = targets(s, p)?;
|
||||
s.edit(|d, _| {
|
||||
if let Some(a) = global_light {
|
||||
d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
}
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
@@ -2506,6 +2514,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
as u8,
|
||||
),
|
||||
};
|
||||
// `Some(None)` turns the frame's custom baseline grid off.
|
||||
let custom_grid = match p.get("baselineGrid") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Object(g)) => {
|
||||
let num = |key: &str, lo: f64, hi: f64| match g.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_f64() {
|
||||
Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)),
|
||||
_ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))),
|
||||
},
|
||||
};
|
||||
let start = num("start", -15552.0, 15552.0)?;
|
||||
let increment = num("increment", 0.0, 15552.0)?;
|
||||
let relative_to = match g.get("relativeTo") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_str()
|
||||
.and_then(designcraft_doc::FrameGridRelative::from_name)
|
||||
.ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?,
|
||||
),
|
||||
};
|
||||
Some(Some((start, increment, relative_to)))
|
||||
}
|
||||
Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))),
|
||||
};
|
||||
// A frame without a custom grid starts from the document's.
|
||||
let doc_grid = {
|
||||
let g = &st.doc.settings.baseline_grid;
|
||||
let relative_to = match g.relative_to {
|
||||
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
|
||||
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
|
||||
};
|
||||
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
|
||||
};
|
||||
let rule_color = match p.get("columnRuleColor") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
|
||||
@@ -2596,6 +2639,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = rule_bottom {
|
||||
o.column_rule_bottom_inset = v;
|
||||
}
|
||||
match custom_grid {
|
||||
Some(Some((start, increment, relative_to))) => {
|
||||
let mut g = o.baseline_grid.unwrap_or(doc_grid);
|
||||
g.start = start.unwrap_or(g.start);
|
||||
g.increment = increment.unwrap_or(g.increment);
|
||||
g.relative_to = relative_to.unwrap_or(g.relative_to);
|
||||
o.baseline_grid = Some(g);
|
||||
}
|
||||
Some(None) => o.baseline_grid = None,
|
||||
None => {}
|
||||
}
|
||||
// Fixed Width columns set the frame's width: n columns, the gutters between them and
|
||||
// the left and right insets.
|
||||
let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0;
|
||||
@@ -4099,6 +4153,26 @@ mod light_tests {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(angle(&s), 45.0);
|
||||
}
|
||||
|
||||
/// The Effects dialog's Angle with Use Global Light on changes the global light, with the
|
||||
/// effects, in one undo step.
|
||||
#[test]
|
||||
fn effects_sets_the_global_light_with_the_effects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.settings.global_light;
|
||||
s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.settings.global_light, 30.0);
|
||||
let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow;
|
||||
assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows");
|
||||
assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -5609,3 +5683,33 @@ mod fill_tint_tests {
|
||||
assert!(stroke_of(&s, b).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_baseline_grid_tests {
|
||||
use super::*;
|
||||
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
|
||||
|
||||
#[test]
|
||||
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
|
||||
let id = ItemId(r["id"].as_u64().unwrap());
|
||||
let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid);
|
||||
// Turning it on with only Relative To takes the document grid's start and increment.
|
||||
let doc = s.doc().unwrap().doc.settings.baseline_grid.clone();
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap();
|
||||
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame }));
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap();
|
||||
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset }));
|
||||
// Fields not given keep the frame's.
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap();
|
||||
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin }));
|
||||
for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] {
|
||||
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin));
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap();
|
||||
assert_eq!(grid(&s), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,13 +907,12 @@ mod tests {
|
||||
let outside = base.join("separate");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let redirected = base.join("Package");
|
||||
match symlink_dir(&outside, &redirected) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
return;
|
||||
}
|
||||
Err(e) => panic!("could not create directory redirect: {e}"),
|
||||
// A symbolic link needs Developer Mode or elevation (os error 1314 without them); a
|
||||
// directory junction needs neither, and `is_redirect` treats both as redirects.
|
||||
if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) {
|
||||
eprintln!("skipped: directory redirects can't be made here");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
|
||||
@@ -922,4 +921,17 @@ mod tests {
|
||||
std::fs::remove_dir(&redirected).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made.
|
||||
#[cfg(windows)]
|
||||
fn make_junction(target: &Path, link: &Path) -> bool {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd.
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/D", "/C"])
|
||||
.raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display()))
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn is_svg_markup(text: &str) -> bool {
|
||||
designcraft_images::is_svg(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Nesting deeper than this is flattened into its parent (hostile input can't exhaust the stack).
|
||||
/// Cap conversion nesting too; the shared image parser bounds XML before usvg runs.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// A converted SVG node, in points.
|
||||
@@ -426,13 +426,16 @@ mod tests {
|
||||
#[test]
|
||||
fn deeply_nested_groups_do_not_exhaust_the_stack() {
|
||||
let mut s = session();
|
||||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let depth = 2000;
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">{}<rect width="10" height="10"/>{}</svg>"#,
|
||||
"<g opacity=\"0.99\">".repeat(depth),
|
||||
"</g>".repeat(depth)
|
||||
);
|
||||
// usvg itself may refuse a document this deep; either way, no crash.
|
||||
let _ = s.execute("edit.pasteSvg", &json!({"svg": svg}));
|
||||
assert!(s.execute("edit.pasteSvg", &json!({"svg": svg})).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
|
||||
return Err(bad("object.layerOptions", "select a placed PDF"));
|
||||
}
|
||||
// Validate every selected PDF before editing any item or recording undo.
|
||||
// Repeated placements of the same source need only one rewrite check.
|
||||
if !hidden.is_empty() {
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(data) = placed_pdf(s, *id)
|
||||
&& checked.insert(std::sync::Arc::as_ptr(&data))
|
||||
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
|
||||
{
|
||||
return Err(bad(
|
||||
"object.layerOptions",
|
||||
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for id in &ids {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
@@ -63,31 +79,277 @@ pub(crate) mod tests {
|
||||
|
||||
/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half).
|
||||
pub(crate) fn two_layer_pdf() -> Vec<u8> {
|
||||
two_layer_pdf_generation(0)
|
||||
}
|
||||
|
||||
fn two_layer_pdf_generation(generation: i32) -> Vec<u8> {
|
||||
two_layer_pdf_options(generation, "", "")
|
||||
}
|
||||
|
||||
fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec<u8> {
|
||||
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
|
||||
let objs = [
|
||||
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]>>>>>>".to_string(),
|
||||
format!(
|
||||
"<</Type/Catalog/Lang(en-GB)/Metadata 7 0 R/OutputIntents[8 0 R]/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]{config}>>>>>>"
|
||||
),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
"<</Type/OCG/Name(Blue)>>".to_string(),
|
||||
"<</Type/OCG/Name(Red)>>".to_string(),
|
||||
"<</Type/Metadata/Subtype/XML/Length 4>>\nstream\n<x/>\nendstream".to_string(),
|
||||
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier(Test)>>".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
|
||||
for o in offs {
|
||||
t.push_str(&format!("{o:010} 00000 n\r\n"));
|
||||
t.push_str(&format!("{o:010} {generation:05} n\r\n"));
|
||||
}
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 {generation} R{trailer}>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
out.extend_from_slice(t.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_preserves_catalog_configuration_and_generations() {
|
||||
for generation in [0, 1] {
|
||||
let original = two_layer_pdf_generation(generation);
|
||||
let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
let appended = std::str::from_utf8(&updated[original.len()..]).unwrap();
|
||||
for token in [
|
||||
"/Lang(en-GB)".to_string(),
|
||||
format!("/Metadata 7 {generation} R"),
|
||||
format!("/OutputIntents[8 {generation} R]"),
|
||||
format!("/Locked[5 {generation} R]"),
|
||||
"/Name(Default)".into(),
|
||||
format!("/Root 1 {generation} R"),
|
||||
] {
|
||||
assert!(appended.contains(&token), "missing {token}: {appended}");
|
||||
}
|
||||
assert!(!appended.contains("9999000"));
|
||||
let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap();
|
||||
assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() {
|
||||
let original = two_layer_pdf();
|
||||
let text = std::str::from_utf8(&original).unwrap();
|
||||
let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
let mut stream_pdf = original[..offset].to_vec();
|
||||
let mut entries = vec![0, 0, 0, 0, 0, 255, 255];
|
||||
for number in 1..=9 {
|
||||
let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() };
|
||||
entries.push(1);
|
||||
entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes());
|
||||
entries.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
stream_pdf.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/Root 1 0 R/W[1 4 2]/Length {}>>\nstream\n", entries.len()).as_bytes());
|
||||
stream_pdf.extend_from_slice(&entries);
|
||||
stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
|
||||
let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] {
|
||||
let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9"));
|
||||
assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() {
|
||||
let original = two_layer_pdf();
|
||||
let text = std::str::from_utf8(&original).unwrap();
|
||||
let first = original.len();
|
||||
let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<</Size 9/Root 1 0 R/Prev {prev:010}>>\n");
|
||||
let main = first + link(0).len();
|
||||
let mut forward = original.clone();
|
||||
forward.extend_from_slice(link(main).as_bytes());
|
||||
let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes());
|
||||
forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
|
||||
let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
let mut cyclic = original;
|
||||
cyclic.extend_from_slice(link(first).as_bytes());
|
||||
cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
|
||||
assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none());
|
||||
}
|
||||
|
||||
fn add_pdf_revision(bytes: &mut Vec<u8>) {
|
||||
let text = std::str::from_utf8(bytes).unwrap();
|
||||
let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(
|
||||
format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Root 1 0 R/Prev {prev}>>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value {
|
||||
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100}))
|
||||
.unwrap()["id"]
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn pixel(s: &Session, x: u32) -> [u8; 4] {
|
||||
let mut renderer = designcraft_render::Renderer::new();
|
||||
renderer.threads = 0;
|
||||
renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() {
|
||||
for generation in [0, 1] {
|
||||
let bytes = two_layer_pdf_options(generation, "", "/Encrypt null");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let id = place(&mut s, &bytes, 0.0);
|
||||
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
|
||||
assert_eq!(pixel(&s, 25), [0, 0, 255, 255]);
|
||||
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() {
|
||||
let mut unsupported = two_layer_pdf();
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut unsupported);
|
||||
}
|
||||
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
|
||||
let good = place(&mut s, &two_layer_pdf(), 0.0);
|
||||
let bad = place(&mut s, &unsupported, 100.0);
|
||||
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().history.redo.is_empty());
|
||||
let before = s.execute("file.serialize", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let redo = s.doc().unwrap().history.redo.len();
|
||||
for ids in [json!([good, bad]), json!([bad, good])] {
|
||||
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("could not be hidden"));
|
||||
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
|
||||
for id in [&good, &bad] {
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true);
|
||||
}
|
||||
}
|
||||
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
|
||||
assert_eq!(pixel(&s, 175), [255, 0, 0, 255]);
|
||||
// A persisted/relinked choice can be unrewritable. Reset must still work.
|
||||
let bad_id = serde_json::from_value(bad.clone()).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas");
|
||||
assert!(s.execute("file.exportPdf", &json!({})).is_err());
|
||||
s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true);
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
|
||||
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
|
||||
let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}"));
|
||||
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}");
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}");
|
||||
}
|
||||
for trailer in ["", "/Prev null"] {
|
||||
let bytes = two_layer_pdf_options(0, "", trailer);
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}");
|
||||
}
|
||||
let mut revised = two_layer_pdf();
|
||||
let prev =
|
||||
std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::<usize>().unwrap();
|
||||
add_pdf_revision(&mut revised);
|
||||
let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>"));
|
||||
assert!(real_prev.contains(&format!("/Prev {prev}.0>>")));
|
||||
for bytes in [revised, real_prev.into_bytes()] {
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_null_trailer_cannot_bypass_hybrid_rejection() {
|
||||
for real_guard in ["", "/XRefStm 0"] {
|
||||
let bytes = two_layer_pdf_options(0, "", real_guard);
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
for fake in ["/Encrypt null", ""] {
|
||||
let comment = format!("% trailer <</Size 9/Root 1 0 R{fake}>>\n");
|
||||
let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1);
|
||||
assert!(inside.contains(&format!("xref\n{comment}")));
|
||||
// Repair-only comments inside a table are always refused, guard or not.
|
||||
assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none());
|
||||
// Before xref or after the dictionary, the real trailer decides.
|
||||
let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1);
|
||||
let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1);
|
||||
for source in [leading, trailing] {
|
||||
assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2);
|
||||
assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_all_still_measures_layerless_and_stub_properties() {
|
||||
use designcraft_doc::PdfCrop;
|
||||
for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<</OCGs[]/D<<>>>>"] {
|
||||
let content = "0 0 1 rg 0 0 50 100 re f";
|
||||
let objs = [
|
||||
format!("<</Type/Catalog/Pages 2 0 R{props}>>"),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>".into(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
|
||||
for at in offsets {
|
||||
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
assert!(designcraft_render::pdf_layers(&bytes).is_empty());
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() {
|
||||
use designcraft_doc::PdfCrop;
|
||||
let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", "");
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0));
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut bytes);
|
||||
}
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none());
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_layers_hide() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -348,17 +348,19 @@ mod placed_vector_tests {
|
||||
/// file is still missing.
|
||||
#[test]
|
||||
fn a_missing_link_shown_by_its_preview_is_still_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing_path = dir.path().join("missing dir").join("photo.jpg");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let asset = doc.assets.values_mut().next().unwrap();
|
||||
std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into());
|
||||
std::sync::Arc::make_mut(asset).link = Some(missing_path.to_str().unwrap().into());
|
||||
let r = s.execute("preflight.run", &json!({})).unwrap();
|
||||
let missing: Vec<&str> =
|
||||
r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect();
|
||||
assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]);
|
||||
assert_eq!(missing, [format!("Missing link: {}", missing_path.display())]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@ mod tests {
|
||||
assert!(command.contains(&"print-scaling=auto-fit"));
|
||||
assert!(!command.iter().any(|a| a.starts_with("sides=")));
|
||||
} else {
|
||||
assert_eq!(command[0], "powershell");
|
||||
assert_eq!(
|
||||
command,
|
||||
["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"]
|
||||
);
|
||||
}
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
|
||||
|
||||
@@ -302,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Paragraph Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
|
||||
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
|
||||
has_doc,
|
||||
create_para
|
||||
),
|
||||
@@ -311,7 +311,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
has_doc,
|
||||
edit_para
|
||||
),
|
||||
|
||||
@@ -1273,6 +1273,35 @@ fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option<V
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// The frame a border drag names must hold the table's story and be editable.
|
||||
fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> {
|
||||
const ID: &str = "table.resizeBorder";
|
||||
if let Some(fid) = super::id_param(p, "frame") {
|
||||
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
|
||||
if it.text_frame().map(|f| f.story) != Some(story) {
|
||||
return Err(bad(ID, "the table is not in that frame's story"));
|
||||
}
|
||||
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
|
||||
return Err(bad(ID, "the frame or its layer is locked"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The change in track size: `delta`, or `size` measured from the current size `cur`.
|
||||
fn border_delta(p: &Value, cur: f64) -> Result<f64> {
|
||||
const ID: &str = "table.resizeBorder";
|
||||
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
|
||||
(Some(d), _) => d,
|
||||
(None, Some(sz)) => sz - cur,
|
||||
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
|
||||
};
|
||||
if !delta.is_finite() {
|
||||
return Err(bad(ID, "`delta`/`size` must be a finite number"));
|
||||
}
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.resizeBorder";
|
||||
let column = match str_param(p, "axis") {
|
||||
@@ -1284,15 +1313,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false);
|
||||
let g = target(s, p, ID)?;
|
||||
let st = s.doc()?;
|
||||
if let Some(fid) = super::id_param(p, "frame") {
|
||||
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
|
||||
if it.text_frame().map(|f| f.story) != Some(g.story) {
|
||||
return Err(bad(ID, "the table is not in that frame's story"));
|
||||
}
|
||||
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
|
||||
return Err(bad(ID, "the frame or its layer is locked"));
|
||||
}
|
||||
}
|
||||
check_border_frame(st, p, g.story)?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let sizes: Vec<f64> = if column {
|
||||
t.columns.iter().map(|c| c.width).collect()
|
||||
@@ -1301,14 +1322,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect()
|
||||
};
|
||||
let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?;
|
||||
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
|
||||
(Some(d), _) => d,
|
||||
(None, Some(sz)) => sz - cur,
|
||||
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
|
||||
};
|
||||
if !delta.is_finite() {
|
||||
return Err(bad(ID, "`delta`/`size` must be a finite number"));
|
||||
}
|
||||
let delta = border_delta(p, cur)?;
|
||||
let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?;
|
||||
let size = new.get(index).copied().unwrap_or(cur);
|
||||
let scaled = keep && index + 1 == sizes.len();
|
||||
@@ -1751,7 +1765,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: O
|
||||
ts.borders = Default::default();
|
||||
}
|
||||
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
|
||||
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32);
|
||||
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX));
|
||||
let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string();
|
||||
let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32);
|
||||
ts.alt_rows = Some(designcraft_doc::AltFills {
|
||||
@@ -2466,3 +2480,22 @@ mod resize_border_tests {
|
||||
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod style_alt_rows_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::table_style_fields;
|
||||
|
||||
#[test]
|
||||
fn style_alt_rows_counts_saturate() {
|
||||
let mut ts = designcraft_doc::TableStyle::default();
|
||||
let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}});
|
||||
table_style_fields(&mut ts, &p, None);
|
||||
let a = ts.alt_rows.unwrap();
|
||||
assert_eq!(a.first, u32::MAX, "first");
|
||||
assert_eq!(a.next, u32::MAX, "next");
|
||||
assert_eq!(a.skip_first, u32::MAX, "skip_first");
|
||||
assert_eq!(a.skip_last, 3, "skip_last");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
|
||||
cmd!(
|
||||
"text.deleteSurrounding",
|
||||
"Delete Surrounding Text",
|
||||
[],
|
||||
None,
|
||||
"{before?: int, after?: int, bytes?: bool, commit?: string} — removes the text around the selection (an input method's delete-surrounding), keeps the selection and its direction, and with `commit` types that text over it: one undo step",
|
||||
has_text,
|
||||
delete_surrounding
|
||||
),
|
||||
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
|
||||
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
|
||||
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
|
||||
@@ -104,6 +113,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"story.options",
|
||||
"Story Options",
|
||||
[],
|
||||
None,
|
||||
"{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}",
|
||||
has_doc,
|
||||
story_options
|
||||
),
|
||||
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
|
||||
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
|
||||
let text = super::text_param(p, "text");
|
||||
@@ -182,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Formatting",
|
||||
[],
|
||||
None,
|
||||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
|
||||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
|
||||
has_text_or_frames,
|
||||
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
|
||||
),
|
||||
@@ -374,7 +392,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Selection Attributes",
|
||||
[],
|
||||
None,
|
||||
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)",
|
||||
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
|
||||
has_doc,
|
||||
selection_attrs
|
||||
),
|
||||
@@ -758,6 +776,71 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
result
|
||||
}
|
||||
|
||||
/// The input method's delete-surrounding: remove `before`/`after` characters (or bytes) around
|
||||
/// the selection, keeping the selection itself and its direction, then optionally type `commit`
|
||||
/// over it. One undo step for the whole batch, through the normal command pipeline (preview
|
||||
/// restoration, panic guard, undo, auto-size and the journal), unlike a private edit.
|
||||
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count = |key: &str| -> Result<usize> {
|
||||
match p.get(key).and_then(Value::as_u64) {
|
||||
Some(v) => usize::try_from(v).map_err(|_| bad("text.deleteSurrounding", format!("{key} is out of range"))),
|
||||
None => Ok(0),
|
||||
}
|
||||
};
|
||||
let before = count("before")?;
|
||||
let after = count("after")?;
|
||||
let bytes = p.get("bytes").and_then(Value::as_bool).unwrap_or(false);
|
||||
let commit = designcraft_tools::ime_commit_text(p.get("commit").and_then(Value::as_str).unwrap_or(""));
|
||||
// What the request removes, from the committed text as it is now: the ranges exclude the
|
||||
// selection and any pre-edit (a pre-edit is only a preview, never in the story).
|
||||
let (before_range, after_range, tracking, length_before) = {
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?;
|
||||
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let (before_range, after_range) = crate::ime_delete_ranges(&story.text, t.anchor, t.focus, before, after, bytes)
|
||||
.ok_or_else(|| bad("text.deleteSurrounding", "the IME text selection is not on character boundaries"))?;
|
||||
(before_range, after_range, st.doc.settings.track_changes && t.cell.is_none(), story.text.len())
|
||||
};
|
||||
s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?;
|
||||
let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
if !after_range.is_empty() {
|
||||
if tracking {
|
||||
crate::cmd::changes::mark_deleted(story, after_range.clone());
|
||||
} else {
|
||||
story.delete(after_range.clone());
|
||||
}
|
||||
}
|
||||
let mut shift = 0usize;
|
||||
if !before_range.is_empty() {
|
||||
if tracking {
|
||||
// Tracked changes keep the text and mark it deleted: the selection does not move.
|
||||
let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone());
|
||||
shift = before_range.end.saturating_sub(new_end);
|
||||
} else {
|
||||
shift = before_range.len();
|
||||
story.delete(before_range.clone());
|
||||
}
|
||||
}
|
||||
sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t });
|
||||
Ok(())
|
||||
})?;
|
||||
if !commit.is_empty() {
|
||||
insert(s, &json!({"text": commit}))?;
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let (anchor, focus) = st.selection.text.map_or((0, 0), |t| (t.anchor, t.focus));
|
||||
let length_after = st.selection.text.and_then(|t| st.doc.text_story(t.story, t.cell)).map_or(0, |story| story.text.len());
|
||||
Ok(json!({
|
||||
"deletedBefore": [before_range.start, before_range.end],
|
||||
"deletedAfter": [after_range.start, after_range.end],
|
||||
"lengthBefore": length_before,
|
||||
"lengthAfter": length_after,
|
||||
"anchor": anchor,
|
||||
"focus": focus,
|
||||
}))
|
||||
}
|
||||
|
||||
fn smart_quotes(s: &Session, text: &str) -> String {
|
||||
if !text.contains(['"', '\'']) {
|
||||
return text.to_string();
|
||||
@@ -991,11 +1074,11 @@ fn sentence_bounds(s: &str, i: usize) -> (usize, usize) {
|
||||
(pa + start, pb)
|
||||
}
|
||||
|
||||
/// Type ▸ Story Direction.
|
||||
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
|
||||
/// The stories of the text selection, the selected cells and the selected text frames.
|
||||
fn selected_stories(s: &Session) -> Result<Vec<StoryId>> {
|
||||
let st = s.doc()?;
|
||||
let mut stories: Vec<StoryId> = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect();
|
||||
stories.dedup();
|
||||
for id in &st.selection.items {
|
||||
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame())
|
||||
&& !stories.contains(&tf.story)
|
||||
@@ -1003,6 +1086,54 @@ fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
stories.push(tf.story);
|
||||
}
|
||||
}
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size).
|
||||
fn story_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "story.options";
|
||||
let on = match p.get("opticalMargin") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?),
|
||||
};
|
||||
let size = match p.get("opticalMarginSize") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.and_then(designcraft_doc::OpticalMargin::clamp_size)
|
||||
.ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?,
|
||||
),
|
||||
};
|
||||
if on.is_none() && size.is_none() {
|
||||
return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize"));
|
||||
}
|
||||
let stories = if p.get("story").is_some() || p.get("frame").is_some() {
|
||||
vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?]
|
||||
} else {
|
||||
selected_stories(s)?
|
||||
};
|
||||
if stories.is_empty() {
|
||||
return Err(bad(CMD, "no story: select text or a text frame, or give story or frame"));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for sid in &stories {
|
||||
let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?;
|
||||
let mut om = st.optical_margin;
|
||||
om.on = on.unwrap_or(om.on);
|
||||
om.size = size.unwrap_or(om.size);
|
||||
if om != st.optical_margin {
|
||||
st.optical_margin = om;
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
Ok(json!({"stories": stories.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Type ▸ Story Direction.
|
||||
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
|
||||
let stories = selected_stories(s)?;
|
||||
s.edit(|d, _| {
|
||||
set_story_direction(d, &stories, vertical);
|
||||
Ok(json!({"stories": stories.len()}))
|
||||
@@ -1836,12 +1967,30 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(),
|
||||
"charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides,
|
||||
"selectedText": selected.trim()});
|
||||
let mut stories = selected_stories(s)?;
|
||||
if stories.is_empty() {
|
||||
stories.push(sid);
|
||||
}
|
||||
out["storyOptions"] = story_options_of(&st.doc, &stories);
|
||||
if bool_or(p, "mixed", false) {
|
||||
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The story options of `stories`, as story.options names them; a value that differs between
|
||||
/// the stories is null.
|
||||
fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value {
|
||||
let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin);
|
||||
let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) };
|
||||
let (mut on, mut size) = (Some(first.on), Some(first.size));
|
||||
for om in values {
|
||||
on = on.filter(|v| *v == om.on);
|
||||
size = size.filter(|v| *v == om.size);
|
||||
}
|
||||
json!({"opticalMargin": on, "opticalMarginSize": size})
|
||||
}
|
||||
|
||||
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
|
||||
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
|
||||
let mut first: Option<serde_json::Map<String, Value>> = None;
|
||||
@@ -2105,7 +2254,7 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect();
|
||||
let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None };
|
||||
let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None };
|
||||
let (applied, available) = measure(&tag);
|
||||
sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available}));
|
||||
}
|
||||
@@ -3280,6 +3429,90 @@ mod bidi_caret_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod story_options_tests {
|
||||
use super::*;
|
||||
|
||||
fn first_x(s: &Session, sid: StoryId) -> f64 {
|
||||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
cs.frames[0].lines[0].glyphs[0].x
|
||||
}
|
||||
|
||||
fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin {
|
||||
s.doc().unwrap().doc.story(sid).unwrap().optical_margin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_options_set_optical_margin_with_one_undo_step_each() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let flush = first_x(&s, sid);
|
||||
// The selected frame's story.
|
||||
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
|
||||
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
|
||||
let hung = first_x(&s, sid);
|
||||
assert!(hung < flush - 1.0, "{hung} {flush}");
|
||||
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap();
|
||||
assert_eq!(story(&s, sid).size, 24.0);
|
||||
assert!(first_x(&s, sid) < hung - 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!story(&s, sid).on);
|
||||
assert_eq!(first_x(&s, sid), flush);
|
||||
// Hostile sizes.
|
||||
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap();
|
||||
assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE);
|
||||
for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] {
|
||||
assert!(s.execute("story.options", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_optical_margin_through_type_para_and_paragraph_styles() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let flush = first_x(&s, sid);
|
||||
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
|
||||
assert!(first_x(&s, sid) < flush - 1.0);
|
||||
let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin;
|
||||
s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap();
|
||||
assert_eq!(ignored(&s), Some(true));
|
||||
assert_eq!(first_x(&s, sid), flush);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ignored(&s), None);
|
||||
s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap();
|
||||
let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin;
|
||||
assert!(resolved(&s));
|
||||
s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap();
|
||||
assert!(!resolved(&s));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(resolved(&s));
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap();
|
||||
assert_eq!(first_x(&s, sid), flush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_attrs_report_the_story_options_with_differences_as_null() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap();
|
||||
let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone();
|
||||
assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0}));
|
||||
s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap();
|
||||
assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story");
|
||||
s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
|
||||
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null}));
|
||||
s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap();
|
||||
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -73,9 +73,256 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.execute("text.insert", &json!({"text": c.to_string()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"variables.convertToText",
|
||||
"Convert Variable to Text",
|
||||
["Type", "Text Variables"],
|
||||
None,
|
||||
"{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined",
|
||||
has_selected_instance,
|
||||
convert_selection
|
||||
),
|
||||
cmd!(
|
||||
"variables.convertAllToText",
|
||||
"Convert to Text",
|
||||
[],
|
||||
None,
|
||||
"{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined",
|
||||
has_doc,
|
||||
convert_all
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The selected text holds a variable instance (InDesign: select the variable, then Convert
|
||||
/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there
|
||||
/// would not last.
|
||||
fn has_selected_instance(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let st = s.active().ok_or("no document")?;
|
||||
let t = st.selection.text.ok_or("no text selected")?;
|
||||
let held = st.doc.endnote_story != Some(t.story)
|
||||
&& st
|
||||
.doc
|
||||
.text_story(t.story, t.cell)
|
||||
.and_then(|x| x.text.get(t.range()))
|
||||
.is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()));
|
||||
if held { Ok(()) } else { Err("no text variable selected".into()) }
|
||||
}
|
||||
|
||||
/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes
|
||||
/// the page of the story's last frame; parent pages and the pasteboard have none.
|
||||
fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option<usize> {
|
||||
designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame)))
|
||||
}
|
||||
|
||||
/// Values of every variable on the pages asked for, as composition shows them (running headers
|
||||
/// included), computed once per page.
|
||||
struct PageValues<'a> {
|
||||
doc: &'a designcraft_doc::Document,
|
||||
running: std::sync::Arc<designcraft_compose::vars::RunningIndex>,
|
||||
pages: std::collections::HashMap<Option<usize>, Vec<String>>,
|
||||
}
|
||||
|
||||
impl PageValues<'_> {
|
||||
fn get(&mut self, i: usize, page: Option<usize>) -> String {
|
||||
let (doc, running) = (self.doc, &self.running);
|
||||
let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running)));
|
||||
v.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any),
|
||||
/// with their variable index.
|
||||
fn instances(text: &str, range: std::ops::Range<usize>, which: Option<usize>) -> Vec<(usize, usize)> {
|
||||
let Some(t) = text.get(range.clone()) else { return Vec::new() };
|
||||
t.char_indices()
|
||||
.filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the
|
||||
/// earlier offsets hold. Returns the change in length before each replaced byte, in text order.
|
||||
fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> {
|
||||
let mut grown = Vec::new();
|
||||
for &(pos, i) in at.iter().rev() {
|
||||
let len = var_char(i).map_or(0, char::len_utf8);
|
||||
let v = value(pos);
|
||||
st.replace(pos..pos + len, &v);
|
||||
grown.push((pos, v.len() as isize - len as isize));
|
||||
}
|
||||
grown.reverse();
|
||||
grown
|
||||
}
|
||||
|
||||
/// Moves byte `b` of a story by the growth of the instances replaced before it.
|
||||
fn shifted(b: usize, grown: &[(usize, isize)]) -> usize {
|
||||
let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum();
|
||||
b.saturating_add_signed(d)
|
||||
}
|
||||
|
||||
/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`.
|
||||
/// Returns the growth of `st`'s own text, as [`replace_instances`].
|
||||
fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Vec::new();
|
||||
}
|
||||
let at = instances(&st.text, 0..st.text.len(), Some(i));
|
||||
let grown = replace_instances(st, &at, &|_| value.to_string());
|
||||
let mut nested = false;
|
||||
for t in st.tables.values_mut() {
|
||||
if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) {
|
||||
for c in &mut std::sync::Arc::make_mut(t).cells {
|
||||
replace_nested(&mut c.text, i, value, depth + 1);
|
||||
}
|
||||
nested = true;
|
||||
}
|
||||
}
|
||||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
if holds(¬e.text, i, depth + 1) {
|
||||
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1);
|
||||
nested = true;
|
||||
}
|
||||
}
|
||||
if nested {
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
}
|
||||
grown
|
||||
}
|
||||
|
||||
/// Whether `st` or text nested in it holds an instance of variable `i`.
|
||||
fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i))
|
||||
|| st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1)))
|
||||
|| st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1)))
|
||||
}
|
||||
|
||||
/// Byte offset in `st` of the anchor of table `id`.
|
||||
fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
|
||||
let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?;
|
||||
let r = st.para_ranges().into_iter().nth(pi)?;
|
||||
Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?)
|
||||
}
|
||||
|
||||
fn convert_selection(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const ID: &str = "variables.convertToText";
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
|
||||
let range = t.range();
|
||||
let (at, values) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
// Text in a cell or a footnote is on the page of its table's or note's anchor.
|
||||
let anchor = match t.cell {
|
||||
None => None,
|
||||
Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64),
|
||||
Some(c) => table_anchor(st, c.table),
|
||||
};
|
||||
let cs = s.cache.get(d, t.story, None);
|
||||
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
|
||||
let at = instances(&text.text, range.clone(), None);
|
||||
let values: std::collections::HashMap<usize, String> =
|
||||
at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect();
|
||||
(at, values)
|
||||
};
|
||||
if at.is_empty() {
|
||||
return Err(bad(ID, "no text variable selected"));
|
||||
}
|
||||
s.edit(|d, sel| {
|
||||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default());
|
||||
// The converted text stays selected.
|
||||
sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t });
|
||||
Ok(json!({"converted": at.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// What converting one story's instances of a variable writes: the value for each instance in
|
||||
/// its own text, and for the text of each table (by id), footnote and endnote, the value on the
|
||||
/// page of its anchor.
|
||||
struct StoryPlan {
|
||||
sid: designcraft_doc::StoryId,
|
||||
own: Vec<(usize, String)>,
|
||||
tables: Vec<(u64, String)>,
|
||||
notes: Vec<String>,
|
||||
endnotes: Vec<String>,
|
||||
}
|
||||
|
||||
fn convert_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "variables.convertAllToText";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?;
|
||||
let (i, plans) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?;
|
||||
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
|
||||
let mut plans = Vec::new();
|
||||
for (sid, st) in &d.stories {
|
||||
if Some(*sid) == d.endnote_story || !holds(st, i, 0) {
|
||||
continue;
|
||||
}
|
||||
let cs = s.cache.get(d, *sid, None);
|
||||
let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos));
|
||||
let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect();
|
||||
let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect();
|
||||
let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect();
|
||||
let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect();
|
||||
plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes });
|
||||
}
|
||||
(i, plans)
|
||||
};
|
||||
s.edit(|d, sel| {
|
||||
for plan in &plans {
|
||||
// A selection in a table cell or a footnote keeps its place in that text: it is
|
||||
// converted first, so the loop below finds nothing left there.
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid)
|
||||
&& let Some(c) = t.cell
|
||||
{
|
||||
let value = match c.footnote_id() {
|
||||
Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)),
|
||||
None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v),
|
||||
};
|
||||
if let Some(v) = value
|
||||
&& let Some(text) = d.text_story_mut(plan.sid, Some(c))
|
||||
{
|
||||
let grown = replace_nested(text, i, v, 1);
|
||||
t.anchor = shifted(t.anchor, &grown);
|
||||
t.focus = shifted(t.focus, &grown);
|
||||
}
|
||||
}
|
||||
let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?;
|
||||
let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect();
|
||||
let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default());
|
||||
let mut nested = false;
|
||||
for (id, v) in &plan.tables {
|
||||
if let Some(t) = st.tables.get_mut(id)
|
||||
&& t.cells.iter().any(|c| holds(&c.text, i, 1))
|
||||
{
|
||||
for c in &mut std::sync::Arc::make_mut(t).cells {
|
||||
replace_nested(&mut c.text, i, v, 1);
|
||||
}
|
||||
nested = true;
|
||||
}
|
||||
}
|
||||
for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) {
|
||||
if holds(¬e.text, i, 1) {
|
||||
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1);
|
||||
nested = true;
|
||||
}
|
||||
}
|
||||
if nested {
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
}
|
||||
// A selection in the story keeps its place in the text.
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) {
|
||||
t.anchor = shifted(t.anchor, &grown);
|
||||
t.focus = shifted(t.focus, &grown);
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Nesting limit for tables in cells and notes (hostile documents).
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
@@ -265,6 +512,180 @@ mod tests {
|
||||
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
|
||||
}
|
||||
|
||||
/// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a
|
||||
/// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold
|
||||
/// "<head>|<head>" (two instances). Returns the two frames' stories.
|
||||
fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
|
||||
let frame = |s: &mut Session, page: usize, y: f64, text: &str| {
|
||||
let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0;
|
||||
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap();
|
||||
s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
|
||||
};
|
||||
for (page, text) in [(0, "Alpha"), (2, "Epsilon")] {
|
||||
let sid = frame(&mut s, page, 200.0, text);
|
||||
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 c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let both = format!("{c}|{c}");
|
||||
let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both));
|
||||
(s, p1, p3)
|
||||
}
|
||||
|
||||
/// Convert Variable to Text turns the selected instance into the value it shows on its own
|
||||
/// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay.
|
||||
#[test]
|
||||
fn convert_variable_to_text_uses_the_instances_page() {
|
||||
let (mut s, p1, p3) = running_heads();
|
||||
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let second = c.len_utf8() + 1;
|
||||
s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap();
|
||||
s.execute("variables.convertToText", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance");
|
||||
assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}"));
|
||||
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step");
|
||||
}
|
||||
|
||||
/// The menu item is enabled only for a selection that holds an instance: not a caret, not
|
||||
/// text without one.
|
||||
#[test]
|
||||
fn convert_variable_to_text_needs_a_selected_instance() {
|
||||
let (mut s, p1, _) = running_heads();
|
||||
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let bar = c.len_utf8();
|
||||
let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok();
|
||||
for (anchor, focus) in [(0, 0), (bar, bar + 1)] {
|
||||
s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap();
|
||||
assert!(!enabled(&s), "{anchor}..{focus}");
|
||||
}
|
||||
s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap();
|
||||
assert!(enabled(&s));
|
||||
}
|
||||
|
||||
/// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3,
|
||||
/// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this
|
||||
/// story is on page 1, its anchor on page 3.
|
||||
fn threaded(s: &mut Session) -> designcraft_doc::StoryId {
|
||||
let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0;
|
||||
let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0;
|
||||
let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap();
|
||||
let from = r["id"].as_u64().unwrap();
|
||||
s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story;
|
||||
let cs = s.cache.get(&d, sid, None);
|
||||
let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos);
|
||||
assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3");
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap();
|
||||
sid
|
||||
}
|
||||
|
||||
/// An instance selected in a table cell becomes the value on the page of the table's anchor.
|
||||
#[test]
|
||||
fn convert_variable_to_text_in_a_table_cell() {
|
||||
let (mut s, ..) = running_heads();
|
||||
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let sid = threaded(&mut s);
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
|
||||
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
|
||||
let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 };
|
||||
s.doc_mut().unwrap().selection =
|
||||
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) });
|
||||
s.execute("variables.convertToText", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon");
|
||||
}
|
||||
|
||||
/// An instance selected in a footnote becomes the value on the page of the note's reference.
|
||||
#[test]
|
||||
fn convert_variable_to_text_in_a_footnote() {
|
||||
let (mut s, ..) = running_heads();
|
||||
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let sid = threaded(&mut s);
|
||||
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
|
||||
let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id;
|
||||
let note = designcraft_doc::CellAddr::footnote(id);
|
||||
let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap();
|
||||
s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel {
|
||||
story: sid,
|
||||
anchor: at,
|
||||
focus: at + c.len_utf8(),
|
||||
frame: None,
|
||||
cell: Some(note),
|
||||
});
|
||||
s.execute("variables.convertToText", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let text = &d.text_story(sid, Some(note)).unwrap().text;
|
||||
assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}");
|
||||
}
|
||||
|
||||
/// Text Variables › Convert to Text turns every instance of the variable into the value on
|
||||
/// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the
|
||||
/// variable.
|
||||
#[test]
|
||||
fn convert_all_instances_to_text() {
|
||||
let (mut s, p1, p3) = running_heads();
|
||||
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let sid = threaded(&mut s);
|
||||
let at_end = |s: &mut Session| {
|
||||
let n = s.doc().unwrap().doc.story(sid).unwrap().len();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap();
|
||||
};
|
||||
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
|
||||
at_end(&mut s);
|
||||
s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap();
|
||||
at_end(&mut s);
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
|
||||
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha");
|
||||
assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon");
|
||||
let st = d.story(sid).unwrap();
|
||||
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell");
|
||||
assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text);
|
||||
assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text);
|
||||
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
|
||||
}
|
||||
|
||||
/// A caret in a table cell or a footnote after an instance stays after the value Convert to
|
||||
/// Text writes there, so typing lands after it.
|
||||
#[test]
|
||||
fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() {
|
||||
for footnote in [false, true] {
|
||||
let (mut s, ..) = running_heads();
|
||||
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
|
||||
let sid = threaded(&mut s);
|
||||
let text = format!("{c}|");
|
||||
let at = if footnote {
|
||||
s.execute("footnote.insert", &json!({"text": text})).unwrap();
|
||||
designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id)
|
||||
} else {
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap();
|
||||
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
|
||||
designcraft_doc::CellAddr { table, row: 0, col: 0 }
|
||||
};
|
||||
let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len();
|
||||
s.doc_mut().unwrap().selection =
|
||||
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) });
|
||||
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text;
|
||||
assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deleting a variable flattens its instances in table cells too and moves the instances of
|
||||
/// later variables down, so cells keep showing the same text.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user