Merge remote-tracking branch 'origin/main' into table-menu-insert

This commit is contained in:
moppeeer
2026-10-24 11:06:43 +02:00
121 changed files with 4721 additions and 1286 deletions
+22 -7
View File
@@ -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
));
}
}
+1 -1
View File
@@ -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}")))?;
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+17
View File
@@ -262,12 +262,29 @@ pub fn specs() -> Vec<CommandSpec> {
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
// A key object needs another object to align to it.
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
st.selection.key = None;
}
} else {
st.selection.items.push(id);
}
st.bump_revision();
ok()
}),
cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
let st = s.doc_mut()?;
if !st.selection.items.contains(&id) {
return Err(super::bad("selection.key", "the key object must be selected"));
}
if st.selection.items.len() < 2 {
return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
}
st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
st.bump_revision();
ok()
}),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
+55 -1
View File
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usiz
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n >= 1 && *n <= page_count)
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
v.push(n - 1);
// A page named twice is one page, as `"2,2"` already is: the list says which
// pages to export, not how many times. `[2, 2]` exported two copies of page 2
// while `"2,2"` exported one.
if !v.contains(&(n - 1)) {
v.push(n - 1);
}
}
Some(v)
}
@@ -1331,3 +1336,52 @@ mod pptx_tests {
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
#[cfg(test)]
mod page_list_tests {
use serde_json::{Value, json};
use crate::Session;
fn doc(pages: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
s
}
/// The same selection written two ways exported different files: `"2,2"` is one page, because
/// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
/// The list says which pages to export, not how many times.
#[test]
fn a_page_named_twice_is_exported_once_either_way() {
let mut s = doc(4);
for (pages, want) in [
(json!("2,2"), 1),
(json!([2, 2]), 1),
(json!([2, 2, 2]), 1),
(json!("2-2,2"), 1),
(json!([1, 2, 1]), 2),
(json!("1,2,1"), 2),
(json!([4, 1]), 2),
(json!("4,1"), 2),
(json!("all"), 4),
] {
let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
}
// The two forms now agree byte for byte, and the pages come out in the document's order
// whichever order the list names them in (which is how the export already behaved).
let mut file = |pages: Value| {
let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
r["base64"].as_str().unwrap_or_default().to_string()
};
assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
// A page that does not exist is still refused, and an empty list still exports nothing.
assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
}
}
+2 -2
View File
@@ -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();
}
@@ -469,7 +469,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);
+90 -35
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Weak};
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
use designcraft_doc::story::floor_char_boundary;
use designcraft_doc::{Document, Selection, StoryId, TextSel};
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]",
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
has_doc, find),
cmd!(
"find.change",
@@ -153,6 +153,9 @@ impl Matcher {
}
}
/// A searched text: a story, or one table cell of it, and the byte range inside.
type Target = (StoryId, Option<CellAddr>, Range<usize>);
/// A search owns its original domain, not the selection used to paint the latest hit.
/// Weak identity avoids retaining an old document merely for this transient cache.
#[derive(Clone)]
@@ -163,7 +166,7 @@ pub(crate) struct FindContext {
selection: Selection,
domain: Domain,
matcher: Matcher,
targets: Vec<(StoryId, Range<usize>)>,
targets: Vec<Target>,
order: Vec<usize>,
target: usize,
offset: usize,
@@ -184,21 +187,25 @@ impl FindContext {
self.target = 0;
self.last_match_end = None;
self.order = (0..self.targets.len()).collect();
let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none());
if self.domain == Domain::Document
&& let Some(t) = st.selection.text
&& let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story)
{
let caret = st.selection.text.filter(|_| st.selection.cells.is_none());
// A caret in a searched cell starts there; one in a footnote starts at its story.
let start = st.selection.text.and_then(|t| {
let at = |want: Option<CellAddr>| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want);
at(t.cell).or_else(|| at(None))
});
if let Some(i) = start.filter(|_| self.domain == Domain::Document) {
self.order.rotate_left(i);
}
let first = self.order.first().and_then(|i| self.targets.get(*i));
self.offset = match first {
Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)),
Some((_, r)) => r.start,
Some((sid, cell, r)) if self.domain != Domain::Selection => {
caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end))
}
Some((_, _, r)) => r.start,
None => 0,
};
if let Some((sid, r)) = first {
let text = target_text(&st.doc, *sid, r)?;
if let Some((sid, cell, r)) = first {
let text = target_text(&st.doc, *sid, *cell, r)?;
if text.get(self.offset.saturating_sub(r.start)..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
}
@@ -209,7 +216,7 @@ impl FindContext {
fn advance_target(&mut self) {
self.target = self.target.saturating_add(1);
self.last_match_end = None;
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start);
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start);
}
fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) {
@@ -222,8 +229,8 @@ impl FindContext {
}
}
fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range<usize>) -> Result<&'a str> {
d.story(sid)
fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Result<&'a str> {
d.text_story(sid, cell)
.ok_or_else(|| bad("find", "no such story"))?
.text
.get(r.clone())
@@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
let targets = match domain {
Domain::Explicit(sid) => {
let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len();
vec![(sid, 0..len)]
vec![(sid, None, 0..len)]
}
Domain::Story | Domain::Selection => {
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
@@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?;
let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?;
let r = if domain == Domain::Selection { t.range() } else { 0..story.len() };
target_text(d, t.story, &r)?;
vec![(t.story, r)]
target_text(d, t.story, None, &r)?;
vec![(t.story, None, r)]
}
Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(),
// Table cells hold their own text outside `stories`; search them right after the story that anchors the table.
Domain::Document => d
.stories
.values()
.flat_map(|x| {
let cells = x.tables.iter().flat_map(move |(id, t)| {
let cols = t.ncols().max(1);
t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len()))
});
std::iter::once((x.id, None, 0..x.len())).chain(cells)
})
.collect(),
};
let mut context = FindContext {
document: Arc::downgrade(d),
@@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
let context = scope(s, p)?;
let st = s.doc()?;
let mut out = Vec::new();
for (sid, r) in &context.targets {
let text = target_text(&st.doc, *sid, r)?;
for (sid, cell, r) in &context.targets {
let text = target_text(&st.doc, *sid, *cell, r)?;
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
if let Some(c) = cell {
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
}
out.push(hit);
}
}
s.find_context = Some(context);
@@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
let targets = scope(s, p)?.targets;
s.edit(|d, sel| {
let mut count = 0usize;
for (sid, r) in targets {
let Some(story) = d.story(sid) else { continue };
for (sid, cell, r) in targets {
let Some(story) = d.text_story(sid, cell) else { continue };
// Collect matches first (positions refer to the original text), apply back to front.
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
@@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
let Some(st) = d.story_mut(sid) else { continue };
let Some(st) = d.text_story_mut(sid, cell) else { continue };
for (range, new) in edits.iter().rev() {
st.replace(range.clone(), new);
if !over.is_empty() && !new.is_empty() {
@@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
}
count += 1;
}
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) {
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
// Repair byte validity once against the final story, without remapping valid offsets.
let range = t.range();
let start = floor_char_boundary(&st.text, range.start);
@@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
let mut context = scope(s, p)?;
let st = s.doc()?;
let mut hit = None;
while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
let text = target_text(&st.doc, sid, &r)?;
while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
let text = target_text(&st.doc, sid, cell, &r)?;
let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?;
if text.get(from..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
@@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
context.advance_empty(text, m.end, b);
continue;
}
hit = Some((sid, a, b));
hit = Some((sid, cell, a, b));
context.last_match_end = Some(b);
if m.is_empty() {
context.advance_empty(text, m.end, b);
@@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
}
context.advance_target();
}
let Some((sid, a, b)) = hit else {
let Some((sid, cell, a, b)) = hit else {
s.find_context = Some(context);
return Ok(Value::Null);
};
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col}));
let stm = s.doc_mut()?;
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell });
stm.bump_revision();
context.acknowledge(stm);
s.find_context = Some(context);
Ok(json!({"story": sid.0, "start": a, "end": b}))
let mut out = json!({"story": sid.0, "start": a, "end": b});
if let Some(c) = cell_json {
out["cell"] = c;
}
Ok(out)
}
#[cfg(test)]
@@ -1007,6 +1034,31 @@ mod tests {
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
#[test]
fn document_scope_searches_and_changes_table_cells() {
let (mut s, sid) = scope_session("Ore 12.15 here");
scope_select(&mut s, sid, 0, 0);
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap();
// The caret sits in the first cell, so Find Next goes to the second cell, then the story.
let p = json!({"find": "Ore 12.15", "scope": "document"});
let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 };
assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}}));
assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell))));
let next = s.execute("find.next", &p).unwrap();
assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}");
let hits = s.execute("find.find", &p).unwrap();
let hits = hits.as_array().unwrap();
assert_eq!(hits.len(), 2);
let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect();
assert_eq!(in_cell.len(), 1);
assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1)));
let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap();
assert_eq!(n["count"], 2);
assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty());
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
}
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
let (mut s, sid) = scope_session("red");
scope_select(&mut s, sid, 3, 3);
@@ -1051,12 +1103,15 @@ mod tests {
p["story"] = json!(sid.0);
p["scope"] = json!("selection");
}
// Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level.
let in_cell = if explicit || note { 0 } else { child.matches("red").count() };
let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() };
s.doc_mut().unwrap().selection = selection.clone();
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1);
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell);
scope_hit(&mut s, &p, sid, 0, 3);
s.doc_mut().unwrap().selection = selection.clone();
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1);
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child);
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell);
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed);
assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets");
p["find"] = json!("blue");
p["change"] = json!("red");
+35 -30
View File
@@ -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() },
}
}
+6 -3
View File
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result<Value> {
"currentPage" => IndexRange::CurrentPage,
"toEndOfStory" => IndexRange::ToEndOfStory,
"suppressPageRange" => IndexRange::SuppressPageRange,
"nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
"nextParagraphs" => {
IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
}
"see" => IndexRange::See(target()),
"seeAlso" => IndexRange::SeeAlso(target()),
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
@@ -103,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option<StoryId>) -> Vec<(StoryId, us
IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)),
IndexRange::NextParagraphs(n) => {
let ranges = st.para_ranges();
let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1);
page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start))
let n = usize::try_from(*n).unwrap_or(usize::MAX);
let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1));
ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start)))
}
_ => start,
};
+45 -3
View File
@@ -161,14 +161,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Pages…",
["Layout", "Pages"],
Some("Cmd+Shift+P"),
"{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
has_doc,
|s, p| {
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
const ID: &str = "layout.pages.insert";
// A count that is given but is not a number of pages used to insert one page:
// `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
let count = match p.get("count").filter(|v| !v.is_null()) {
None => 1,
Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
Some(n) => n,
None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
},
};
let st = s.doc()?;
let n = st.doc.page_count();
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
// pins deliberately.
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
s.edit(|d, _| {
let ids = d.insert_pages(Some(after), count, parent)?;
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
@@ -2497,3 +2508,34 @@ mod parent_base_tests {
#[cfg(test)]
#[path = "parent_delete_tests.rs"]
mod parent_delete_tests;
#[cfg(test)]
mod insert_count_tests {
use serde_json::json;
use crate::Session;
/// A count that is given but is not a number of pages used to insert one page anyway:
/// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
/// to 9999. A position past the last page appends, which is deliberate.
#[test]
fn a_page_count_insert_pages_cannot_mean_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
assert_eq!(total(&mut s), 3, "one page added");
for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
let e = s.execute("layout.pages.insert", &params).expect_err("refused").to_string();
assert!(e.contains("1 to 9999 pages"), "{params}: {e}");
}
// Nothing was inserted by any of those.
assert_eq!(s.doc().unwrap().doc.page_count(), 3);
// A position past the last page still appends, which `insert_pages_bounds.rs` pins.
let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap();
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4)));
// The positions it does have still work: after the first page of three.
let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap();
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6)));
}
}
+14 -4
View File
@@ -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")]
{
@@ -480,7 +480,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 +490,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 {
+36 -25
View File
@@ -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> = {
+46 -6
View File
@@ -686,7 +686,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Object & Layout", "Align"],
None,
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
has_selection,
align
),
@@ -708,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
|s, p| {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
s.prefs.polygon_sides = (n as u32).clamp(3, 100);
s.prefs.polygon_sides = n.clamp(3, 100) as u32;
}
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
let vertical = bool_or(p, "vertical", false);
let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
s.edit(|d, sel| {
@@ -2517,7 +2517,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
o.columns = (v as u32).clamp(1, 40);
o.columns = v.clamp(1, 40) as u32;
}
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
@@ -3074,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>)
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
let key = s.doc()?.selection.key;
// A key object counts only while it is one of several items aligned. With one, InDesign's
// Align To switches to Key Object, so it is also the default target.
let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
if key.is_some() {
"keyObject".into()
} else if ids.len() > 1 {
"selection".into()
} else {
"page".into()
}
});
s.edit(|d, _| {
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
for id in &ids {
@@ -3788,6 +3798,21 @@ mod polygon_tests {
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
}
#[test]
fn polygon_sides_clamp_before_narrowing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
assert_eq!(r["sides"], 100);
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
assert_eq!(r["sides"], 100);
let id =
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
.as_u64()
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
}
}
#[cfg(test)]
@@ -4498,6 +4523,21 @@ mod column_rule_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(opts(&s), before);
}
/// A column count past `u32::MAX` was cast to `u32` before the clamp, so
/// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
#[test]
fn huge_column_count_clamps_to_the_maximum() {
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"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
assert_eq!(columns(&s), want, "columns {given}");
}
}
}
#[cfg(test)]
+266 -4
View File
@@ -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();
+177 -15
View File
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
}
/// Thread new frames until the story fits: first into the columns after the last frame's column
/// on its page (when it sits in one), then one frame per column on each page it adds. With
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
/// removed again, except the first on a page it didn't add; a page it added that holds only such
/// a frame goes with it. Returns pages added.
/// on its page (when it sits in one), then one frame per column on each page that follows it,
/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
/// the last frame's page, each with one frame on the live area with the page's columns instead.
/// Empty frames it made at the end of the thread are removed again; a page it added that holds
/// only such a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut made = Vec::new();
let mut new_pages = Vec::new();
let mut first = true;
// The next page of the document autoflow may set frames on; it only moves forward.
let mut following = 0;
loop {
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { break };
@@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let rects = flow_rects(d, dir, vertical, page);
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
for r in rest.iter().take(want) {
// Whole pages: an estimate that stops inside one would leave its later columns out
// of the thread for good. The cleanup below takes the frames the text doesn't reach.
for r in rest {
prev = add_flow_frame(d, si, *r, layer, prev)?;
made.push(prev);
}
continue;
}
}
following = following.max(page + 1);
if !primary && following < d.page_count() {
let mut left = want;
while left > 0 && following < d.page_count() {
let Some((si, _)) = d.page_loc(following) else { break };
for r in flow_rects(d, dir, vertical, following) {
prev = add_flow_frame(d, si, r, layer, prev)?;
made.push(prev);
left = left.saturating_sub(1);
}
following += 1;
}
continue;
}
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
// Past the following pages, `page` is the document's last: autoflow adds pages at its end.
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
}
let page = d.page_of_item(f);
if d.page_of_item(*before) != page {
// The first frame on its page: the estimate added more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds.
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
// The first frame on its page: the estimate asked for more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds; a page the
// document had keeps whatever else is on it.
match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
Some(pg) => {
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
}
None => {
d.remove_item(f)?;
}
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
} else {
d.remove_item(f)?;
}
@@ -568,6 +595,141 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A facing-pages document with two columns, its inside margin wider than the outside one, so
/// left and right pages have their columns in different places. A graphic frame sits on the
/// page `marker` (0-based). Returns the session and the marker's id.
fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
let mut s = Session::new();
let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
.unwrap();
let d = &s.doc().unwrap().doc;
let (si, _) = d.page_loc(marker).unwrap();
let m = d.page(marker).unwrap().margin_rect();
let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
// Selected, an empty frame would take the placed text.
s.execute("edit.deselectAll", &json!({})).unwrap();
(s, id)
}
/// Every frame of the story on the column of its page, the story running through the pages in
/// order, two frames a page.
fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
}
}
#[test]
fn autoflow_fills_the_pages_that_follow_before_adding_any() {
// Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
// pages the document already had. InDesign threads frames on those first.
let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap();
let (mut s, marker) = facing_document(6, 2);
let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
let sid = StoryId(r["story"].as_u64().unwrap());
let frames = d.story(sid).unwrap().frames.clone();
// Past page 3, which holds the marker: frames go on a page whatever else it holds.
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
assert_frames_in_columns(d, &frames);
// The text ends before the document does: no empty frame after it.
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_pages_at_the_end_of_the_document() {
let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap();
let (mut s, marker) = facing_document(3, 2);
let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() > 4, "{r}");
assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
// The document's own pages come first, the added ones after the last of them.
assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
assert_frames_in_columns(d, &frames);
assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
// One undo step takes the frames, the pages and the story away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_fills_every_column_of_a_following_page() {
// A first frame across the whole live area holds twice what a column does, so autoflow
// asks for half the frames it needs. Asked page by page, it left the later columns of a
// page out of the thread and went on to the next page.
let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
for (k, f) in frames[1..].iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
// A small first frame makes autoflow ask for more frames than the columns need; the ones
// it set on the following pages past the end of the text go again, the pages stay.
let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
assert_eq!(d.page_count(), 8);
let sid = StoryId(r["story"].as_u64().unwrap());
let frames = &d.story(sid).unwrap().frames;
assert!(frames.len() >= 2, "{r}");
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), frames.len());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
+59 -23
View File
@@ -298,7 +298,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Cell Options",
[],
None,
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -998,6 +998,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
// composed so far, which the IDML import reports (#1468).
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
},
None => None,
};
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -1048,6 +1057,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
if let Some(v) = rotation {
cell.rotation = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1288,6 +1300,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") {
@@ -1299,15 +1340,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()
@@ -1316,14 +1349,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();
@@ -1420,14 +1446,14 @@ fn parse_alt(v: &Value) -> Option<AltFills> {
}
let d = AltFills::default();
Some(AltFills {
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
})
}
@@ -1575,7 +1601,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1859,6 +1885,16 @@ mod style_tests {
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
// which the IDML import reports (#1468).
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
}
for bad in [45, 1, 100] {
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
}
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
+57 -1
View File
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// A character fill must name a swatch of the document.
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
validate_char_size(attrs, command_id)?;
match &attrs.fill {
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
None => Ok(()),
}
}
/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
match attrs.size {
Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
}
_ => Ok(()),
}
}
/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
@@ -2088,7 +2105,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}));
}
@@ -3911,3 +3928,42 @@ mod pick_up_format_tests {
let _ = ItemId(rect);
}
}
#[cfg(test)]
mod size_bounds_tests {
use serde_json::json;
use crate::Session;
/// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative
/// size was accepted the same way, and 1e9 pt asked the composer for a line a million times
/// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt.
#[test]
fn a_type_size_outside_the_range_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
let select = |s: &mut Session| {
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
};
for size in [0.0, -12.0, 0.05, 1297.0, 1e9] {
select(&mut s);
let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string();
assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}");
}
// The sizes in range still apply, including the ends.
for size in [0.1, 12.0, 1296.0] {
select(&mut s);
s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}"));
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size));
}
// And the styles that carry character attributes are checked the same way.
for (command, params) in [
("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})),
("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})),
] {
let e = s.execute(command, &params).expect_err("refused").to_string();
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
+47 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Hyperlink Destination…",
["Type", "Hyperlinks & Cross-References"],
None,
"{name} — a text anchor at the insertion point",
"{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
in_story_text,
create_anchor
),
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
fn create_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
if name.trim().is_empty() {
return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
}
// Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
// `hyperlink.create` take the first they find, and a file writes both under one name.
if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
}
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -215,6 +223,44 @@ mod tests {
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
}
/// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
/// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
/// exported file writes both destinations under one name.
#[test]
fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
let select = |s: &mut Session, story: serde_json::Value, from: usize| {
s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
};
select(&mut s, a["story"].clone(), 0);
s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
// The same name again, in the same story and in another one.
select(&mut s, a["story"].clone(), 10);
let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
assert!(e.contains("already in this document"), "{e}");
let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
select(&mut s, b["story"].clone(), 0);
assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
// A name of spaces is not a name.
assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
// One anchor, so a reference by name can only mean that one.
let anchors = s.execute("anchor.list", &json!({})).unwrap();
assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
// Another name is fine.
s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
// The check is on the name, not on where it is: a name that differs only in case is a
// different name, as it is everywhere else in the document.
select(&mut s, b["story"].clone(), 0);
s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
}
#[test]
fn cross_reference_into_a_story_with_a_running_header_composes() {
let mut s = Session::new();
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.map(ToString::to_string)
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into())
}
+1 -2
View File
@@ -21,8 +21,7 @@ use std::path::{Path, PathBuf};
/// refused path is never handed to the file system; callers treat it as a missing file.
pub fn document_path(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
let path = craft_docpath::resolve(stored, document.as_deref())?;
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
craft_docpath::resolve_absolute(stored, document.as_deref())
}
/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
+30 -20
View File
@@ -542,17 +542,7 @@ impl Session {
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
check_params(id, params)?;
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
self.check_enabled(spec, id, params)?;
// Preview is temporary. Save, merge, and every other edit restore the template first.
// Queries stay read-only so the panel can poll fields during a preview. `data.preview`
// itself is the one command that fills the live document.
@@ -598,11 +588,38 @@ impl Session {
st.doc = Arc::new(d);
}
}
// Auto-size frames fit the text the command changed, in the same undo step.
self.autosize_changed(spec, before.as_ref());
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Fails with `Disabled` unless the command is enabled or names objects it can act on.
fn check_enabled(&self, spec: &CommandSpec, id: &str, params: &Value) -> Result<()> {
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
Ok(())
}
/// Auto-size frames fit the text the command changed, in the same undo step.
fn autosize_changed(&mut self, spec: &CommandSpec, before: Option<&(u64, Arc<Document>, Selection)>) {
if spec.undoable
&& let Some(st) = self.active_mut()
{
let prev = before.as_ref().filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
let prev = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) {
let todo = autosize::candidates(&st.doc, prev);
if !todo.is_empty() {
@@ -614,13 +631,6 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
+2 -2
View File
@@ -333,7 +333,7 @@ impl Parser<'_> {
'+' => Node::Sym("+".into(), Class::Bin),
'-' => Node::Sym("−".into(), Class::Bin),
'*' => Node::Sym("∗".into(), Class::Bin),
'=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel),
'<'..='>' => Node::Sym(c.to_string(), Class::Rel),
',' | ';' => Node::Sym(c.to_string(), Class::Punct),
'\'' => Node::Sym("′".into(), Class::Plain),
c => Node::Sym(c.to_string(), Class::Plain),
@@ -390,7 +390,7 @@ impl Parser<'_> {
if self.peek().is_none() {
break;
}
if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(|c| c.is_ascii_alphabetic()) {
if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) {
self.i += 6;
break;
}
+5 -5
View File
@@ -408,7 +408,7 @@ fn remove_emptied(dir: &Path) {
let path = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::<u64>().is_ok());
let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::<u64>().is_ok());
// Metadata whose document has gone, and a file a crash left half written.
if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) {
left.push(path);
@@ -726,8 +726,8 @@ fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> {
for entry in entries {
let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let path = entry.path();
if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok())
if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok())
{
ids.insert(uid);
}
@@ -756,10 +756,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("designcraft") {
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = std::fs::read_to_string(p.with_extension("json"))
.ok()
+105
View File
@@ -1651,3 +1651,108 @@ fn a_line_fits_when_its_baseline_does() {
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
}
/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
#[test]
fn a_click_in_the_selection_makes_the_key_object() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
// On the part of the key object that the aligned object never covers, away from the handles.
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap();
};
click(&mut s);
assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected");
// The key is the right-hand object, so aligning left edges to the selection would move it.
s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap();
let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0;
assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b));
assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a));
// The Align panel and the Control panel send no target: the key object is the default one.
s.execute("object.align", &json!({"edge": "top"})).unwrap();
let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0;
assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b));
assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a));
click(&mut s);
assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key");
}
/// Dragging one of several selected objects moves them all and makes no key object.
#[test]
fn dragging_the_selection_makes_no_key_object() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
}
/// Shift-clicking the key object out of the selection drops the key.
#[test]
fn deselecting_the_key_object_drops_the_key() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("selection.key", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b)));
s.execute("selection.toggle", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
}
/// A key object needs another object to align to: with one object selected there is nothing to key
/// against, and the stored key would turn that object into the target as soon as a second one joins.
#[test]
fn a_single_selected_object_cannot_be_the_key_object() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
assert!(s.execute("selection.key", &json!({"id": a})).is_err());
assert_eq!(s.doc().unwrap().selection.key, None);
s.execute("selection.toggle", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None, "the second object joining must not find a key already set");
}
/// With the selection down to the key object alone, Align no longer has a key object: a single
/// object aligns to the page, as before.
#[test]
fn a_lone_key_object_aligns_to_the_page() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("selection.key", &json!({"id": b})).unwrap();
s.execute("selection.toggle", &json!({"id": a})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
s.execute("object.align", &json!({"edge": "left"})).unwrap();
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
}
/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes
/// the page.
#[test]
fn aligning_the_key_object_alone_takes_the_page() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("selection.key", &json!({"id": b})).unwrap();
s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap();
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
}
+70
View File
@@ -105,3 +105,73 @@ fn a_relative_link_resolves_beside_the_document() {
assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_relative_link_in_an_unsaved_document_is_missing() {
// With no document path there is nothing to resolve against; the working directory is not used.
let mut s = document_with_links(Path::new("doc.designcraft"), &["logo.png", "img/logo.png"]);
s.doc_mut().unwrap().path = None;
let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap());
assert!(seen.is_empty(), "{seen:?}");
for row in rows.as_array().unwrap() {
assert_eq!(row["status"], "missing", "{row}");
assert_eq!(row["refused"], "the path is not valid", "{row}");
}
}
#[cfg(unix)]
#[test]
fn relink_folder_never_leaves_the_chosen_folder() {
// A stored name with no separator can still be a `file:` URL that decodes to a path elsewhere.
let dir = folder("relink-outside");
let chosen = dir.join("chosen");
std::fs::create_dir_all(&chosen).unwrap();
let outside = dir.join("outside.png");
std::fs::write(&outside, png()).unwrap();
let encoded = format!("file:{}", outside.to_string_lossy().replace('/', "%2F"));
let mut s = document_with_links(&dir.join("doc.designcraft"), &[&encoded]);
let (out, seen) =
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&outside), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn relink_folder_takes_the_chosen_folder_as_one_folder() {
// `a\b` is one folder name on Unix; read as a stored path it would be `a/b`.
let dir = folder("relink-backslash");
let chosen = dir.join("a\\b");
std::fs::create_dir_all(&chosen).unwrap();
std::fs::create_dir_all(dir.join("a/b")).unwrap();
let elsewhere = dir.join("a/b/x.png");
std::fs::write(&elsewhere, png()).unwrap();
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
let (out, seen) =
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&elsewhere), "{seen:?}");
// A folder given with `..` is still that folder.
let real = dir.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("x.png"), png()).unwrap();
let roundabout = real.join("..").join("real");
let out = s.execute("links.relinkFolder", &json!({"dir": roundabout.to_string_lossy(), "missingOnly": false})).unwrap();
assert_eq!(out["relinked"], 1, "{out}");
// Through a symlink, `alias/..` is the link target's parent, not the folder holding `alias`.
let inner = dir.join("inner");
std::fs::create_dir_all(inner.join("sub")).unwrap();
std::os::unix::fs::symlink(inner.join("sub"), dir.join("alias")).unwrap();
let beside_alias = dir.join("x.png");
std::fs::write(&beside_alias, png()).unwrap();
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
let (out, seen) = crate::io::touched(|| {
s.execute("links.relinkFolder", &json!({"dir": dir.join("alias").join("..").to_string_lossy(), "missingOnly": false})).unwrap()
});
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&beside_alias), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+23
View File
@@ -1117,3 +1117,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
assert!(!convert_enabled(&s, "Body"));
check(&s);
}
#[test]
fn alternating_fill_counts_saturate_instead_of_wrapping() {
let (mut s, _, _) = session_with_frame();
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
.unwrap();
let t = s.execute("table.get", &json!({})).unwrap();
let a = &t["options"]["altRows"];
assert_eq!(a["first"], u32::MAX);
assert_eq!(a["skipFirst"], u32::MAX);
assert_eq!(a["skipLast"], u32::MAX);
assert_eq!(a["next"], 1);
}
#[test]
fn index_next_paragraphs_saturates_instead_of_wrapping() {
let (mut s, _, _) = session_with_frame();
s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
let l = s.execute("index.list", &json!({})).unwrap();
assert_eq!(l[0]["range"]["type"], "nextParagraphs");
assert_eq!(l[0]["range"]["value"], u32::MAX);
}