Merge main into feat/typeshortcuts

This commit is contained in:
aiongg committed 2026-11-03 19:15:02 +00:00
commit fa8684e30b
269 files changed
+24329 -1910

No files matched your search

+197 -3
View File
@@ -66,6 +66,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
join_paths
),
cmd!(
"path.deletePoints",
"Delete Points",
[],
None,
"{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}",
has_doc,
delete_points
),
cmd!(
"path.convertPoints",
"Convert Point",
@@ -162,6 +171,66 @@ fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Delete Point(s): removes the given points, or the ones selected with Direct Selection.
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "path.deletePoints";
let points: Vec<(ItemId, usize, usize)> = match p.get("points") {
Some(v) => serde_json::from_value::<Vec<(u64, usize, usize)>>(v.clone())
.map_err(|e| bad(ID, format!("points: [[id, subpath, index], …] ({e})")))?
.into_iter()
.map(|(id, si, ai)| (ItemId(id), si, ai))
.collect(),
None => s.doc()?.selection.anchors.clone(),
};
if points.is_empty() {
return Err(bad(ID, "no points selected"));
}
// Each subpath's points, worked from the last subpath and the last point back, so the indices
// still to delete keep their place.
let mut groups: Vec<((ItemId, usize), Vec<usize>)> = Vec::new();
for (id, si, ai) in points {
match groups.iter_mut().find(|(k, _)| *k == (id, si)) {
Some((_, v)) => v.push(ai),
None => groups.push(((id, si), vec![ai])),
}
}
groups.sort_by_key(|((id, si), _)| std::cmp::Reverse((id.0, *si)));
s.edit(|d, sel| {
let (mut deleted, mut removed) = (0usize, Vec::new());
for ((id, si), mut ais) in groups {
if d.item(id).is_none() {
continue;
}
path_editable(d, id, ID)?;
let Some(it) = d.item_mut(id) else { continue };
let Some(sp) = it.path.subpaths.get_mut(si) else { continue };
ais.sort_unstable();
ais.dedup();
for ai in ais.into_iter().rev() {
if ai < sp.anchors.len() {
sp.anchors.remove(ai);
deleted += 1;
}
}
// A subpath needs two points (three to stay closed).
let (left, closed) = (sp.anchors.len(), sp.closed);
if left < 2 {
it.path.subpaths.remove(si);
} else if closed && left < 3 {
sp.closed = false;
}
as_path(&mut it.shape);
if it.path.subpaths.is_empty() {
d.remove_item(id)?;
sel.items.retain(|i| *i != id);
removed.push(id.0);
}
}
sel.anchors.clear();
Ok(json!({"deleted": deleted, "removed": removed}))
})
}
/// Convert Point: the selected points (or every point of the selected paths) to a point type.
fn convert_points(s: &mut Session, p: &Value) -> Result<Value> {
let kind = super::str_param(p, "kind").unwrap_or("plain").to_string();
@@ -232,7 +301,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
s.edit(|d, _| {
s.edit(|d, sel| {
path_editable(d, id, cmd)?;
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let scale = inv.determinant().abs().sqrt().max(1e-9);
@@ -257,6 +326,8 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
return Err(bad(cmd, "the drag didn't touch the path"));
};
let sp = it.path.subpaths[si].clone();
// The subpath list changes under the stored point indices: they would name other points.
sel.anchors.retain(|a| a.0 != id);
if smooth {
it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale);
} else {
@@ -299,7 +370,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?;
let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
s.edit(|d, _| {
s.edit(|d, sel| {
path_editable(d, id, "path.addAnchor")?;
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
@@ -313,6 +384,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let t = t.clamp(1e-4, 1.0 - 1e-4);
let ai = it.path.subpaths[si].insert_anchor(seg, t);
as_path(&mut it.shape);
// Selected points after the new one move up an index, so they stay the same points.
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) {
a.2 += 1;
}
Ok(json!({"subpath": si, "anchor": ai}))
})
}
@@ -328,15 +403,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
}
sp.anchors.remove(ai);
// A subpath needs two points (three to stay closed).
if sp.anchors.len() < 2 {
let removed_subpath = sp.anchors.len() < 2;
if removed_subpath {
it.path.subpaths.remove(si);
} else if sp.closed && sp.anchors.len() < 3 {
sp.closed = false;
}
as_path(&mut it.shape);
// The deleted point leaves the selection; the selected points after it (or the subpaths
// after a removed one) move down an index.
sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath)));
for a in sel.anchors.iter_mut().filter(|a| a.0 == id) {
if removed_subpath && a.1 > si {
a.1 -= 1;
} else if !removed_subpath && a.1 == si && a.2 > ai {
a.2 -= 1;
}
}
if it.path.subpaths.is_empty() {
d.remove_item(id)?;
sel.items.retain(|i| *i != id);
sel.anchors.retain(|a| a.0 != id);
return Ok(json!({"deleted": true}));
}
Ok(json!({"deleted": false}))
@@ -426,6 +513,27 @@ mod tests {
}
/// Join Path closes one open path, and joins two open paths at their nearest ends.
/// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each
/// point's in its own spread); without one a point takes the shared `dx`, `dy`.
#[test]
fn move_anchors_takes_a_movement_per_point() {
let mut s = crate::Session::new();
s.execute("file.new", &serde_json::json!({})).unwrap();
let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let at = |s: &crate::Session, i: usize| {
let d = &s.doc().unwrap().doc;
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
it.xf * it.path.subpaths[0].anchors[i].p
};
let (a0, a1) = (at(&s, 0), at(&s, 1));
s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap();
assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own");
assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one");
for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] {
assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}");
}
}
#[test]
fn join_path_closes_one_path_and_joins_two() {
let mut s = Session::new();
@@ -454,6 +562,63 @@ mod tests {
}
/// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical.
/// Delete Point(s) removes the selected points of several paths in one step; a triangle left
/// with two points opens, and Undo brings every point back.
#[test]
fn delete_points_removes_the_selected_points_of_several_paths() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172], "shape": "polygon", "sides": 3})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"anchors": [[a, 0, 0], [a, 0, 2], [b, 0, 1]]})).unwrap();
let r = s.execute("path.deletePoints", &json!({})).unwrap();
assert_eq!(r["deleted"], 3);
let d = &s.doc().unwrap().doc;
let pa = &d.item(ItemId(a)).unwrap().path.subpaths[0];
assert_eq!(pa.anchors.len(), 2, "two of the square's four points are left");
assert!(!pa.closed, "two points can't stay closed");
assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 2);
assert!(s.doc().unwrap().selection.anchors.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 4, "one undo step");
assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 3);
// A line's two points: the line goes.
let l = s.execute("path.create", &json!({"anchors": [{"p": [72, 300]}, {"p": [172, 300]}]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("path.deletePoints", &json!({"points": [[l, 0, 0]]})).unwrap();
assert_eq!(r["removed"], json!([l]));
assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none());
assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err());
// Named points need no selected object.
s.execute("selection.set", &json!({"ids": []})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
let r = s.execute("path.deletePoints", &json!({"points": [[b, 0, 0]]})).unwrap();
assert_eq!(r["deleted"], 1);
// Edit › Clear with points selected deletes the points, not their paths.
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays");
// Cut copies whole objects, so it removes whole objects.
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none(), "the cut object is gone");
}
/// Shift-adding an object to a point selection drops the points, so Clear deletes the objects.
#[test]
fn clear_after_adding_an_object_to_a_point_selection_deletes_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
s.execute("selection.set", &json!({"ids": [b], "add": true})).unwrap();
assert!(s.doc().unwrap().selection.anchors.is_empty());
s.execute("edit.clear", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none());
assert!(s.doc().unwrap().doc.item(ItemId(b)).is_none());
}
#[test]
fn convert_point_kinds() {
let mut s = Session::new();
@@ -486,16 +651,45 @@ mod tests {
let pts: Vec<Value> = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect();
let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap();
let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
s.execute("selection.set", &json!({"anchors": [[id, 0, 20]]})).unwrap();
let drag: Vec<Value> = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect();
s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap();
let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
assert!(after < n, "{after} < {n}");
assert!(s.doc().unwrap().selection.anchors.is_empty(), "stored point indices are stale after the path is rebuilt");
let drag: Vec<Value> = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect();
let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap();
assert_eq!(r["subpaths"], 2, "a gap in the middle");
assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err());
}
/// The Direct Selection's selected points stay the same points when the path changes under
/// them: a point added before them, one of them deleted, the path reversed.
#[test]
fn selected_anchors_follow_added_deleted_and_reversed_points() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p;
s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap();
let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::<Vec<_>>();
let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect();
// A point added on the first side, before both.
let first = point(&s, (0, 0));
let second = point(&s, (0, 1));
let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0];
s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap();
assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::<Vec<_>>(), before, "after an added point");
// One of them deleted: the other stays selected, the same point.
let (si, ai) = picked(&s)[0];
s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap();
assert_eq!(picked(&s).len(), 1);
assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point");
// Reversed: still the same point.
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing");
}
#[test]
fn add_convert_delete_anchor_points() {
let mut s = Session::new();
+4
View File
@@ -210,6 +210,10 @@ fn select_range(range: &str, count: usize, idx: &mut Vec<usize>, seen: &mut std:
}
fn parse_record_no(s: &str) -> Result<u32, String> {
// `parse` alone would accept a leading '+'.
if !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!("bad record \"{s}\""));
}
s.parse::<u32>().map_err(|_| format!("bad record \"{s}\""))
}
+8
View File
@@ -1179,6 +1179,14 @@ fn select_records_dedupes_large_ranges_in_first_seen_order() {
assert_eq!(&idx[..3], &[4, 0, 1]);
}
#[test]
fn select_records_rejects_signed_record_numbers() {
for (tok, bad) in [("+2", "+2"), ("1-+3", "+3"), ("+1,+4", "+1")] {
let err = parse::select_records(5, "range", 1, tok, None).unwrap_err();
assert!(err.contains(&format!("bad record \"{bad}\"")), "{tok}: {err}");
}
}
#[test]
fn oversized_data_file_is_refused_before_reading() {
let dir = scratch("oversized");
+47 -4
View File
@@ -8,6 +8,21 @@ use serde_json::{Value, json};
use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
use crate::{HistoryEntry, Result, Session};
/// `anchors` of `selection.set`: the points that exist, once each.
fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> {
let mut out: Vec<(ItemId, usize, usize)> = Vec::new();
for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() {
let n = |i: usize| v.get(i).and_then(Value::as_u64);
let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue };
let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue };
let a = (ItemId(id), si, ai);
if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) {
out.push(a);
}
}
out
}
fn can_undo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
}
@@ -235,20 +250,36 @@ pub fn specs() -> Vec<CommandSpec> {
s.typing_format = None;
Ok(json!({"redone": e.label}))
}),
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
let ids = ids_param(p, "ids").unwrap_or_default();
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| {
let mut ids = ids_param(p, "ids").unwrap_or_default();
let add = bool_or(p, "add", false);
let content = bool_or(p, "content", false);
let st = s.doc_mut()?;
let anchors = anchors_param(&st.doc, p);
for (id, _, _) in &anchors {
if !ids.contains(id) {
ids.push(*id);
}
}
let ids: Vec<ItemId> = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();
if add {
// Adding objects (no points) leaves no live points: Delete would otherwise remove
// the stale points instead of the objects.
if anchors.is_empty() {
st.selection.anchors.clear();
}
for i in ids {
if !st.selection.items.contains(&i) {
st.selection.items.push(i);
}
}
for a in anchors {
if !st.selection.anchors.contains(&a) {
st.selection.anchors.push(a);
}
}
} else {
st.selection = Selection { items: ids, content, ..Default::default() };
st.selection = Selection { items: ids, content, anchors, ..Default::default() };
}
st.selection.text = None;
st.selection.cells = None;
@@ -262,12 +293,15 @@ 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);
// Its points go with it: arrow keys and Delete act on the selected points.
st.selection.anchors.retain(|a| a.0 != id);
// 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.selection.anchors.clear();
}
st.bump_revision();
ok()
@@ -320,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear",
["Edit"],
Some("Delete"),
"{ids?} — delete selected items (or the selected text or ruler guides)",
"{ids?} — delete selected items (or the selected text, points or ruler guides)",
has_doc,
|s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
@@ -329,6 +363,10 @@ pub fn specs() -> Vec<CommandSpec> {
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
return s.execute("guide.delete", &json!({}));
}
// Points selected with Direct Selection: they go, not their paths (as InDesign).
if p.get("ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
return s.execute("path.deletePoints", &json!({}));
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
@@ -362,6 +400,11 @@ pub fn specs() -> Vec<CommandSpec> {
return Ok(json!({"text": text}));
}
copy_items(s)?;
// The whole objects are on the clipboard, so the whole objects go, not just selected points.
if p.get("ids").is_none() {
let ids: Vec<u64> = s.doc()?.selection.items.iter().map(|i| i.0).collect();
return s.execute("edit.clear", &json!({"ids": ids}));
}
s.execute("edit.clear", p)
}),
cmd!(
+1
View File
@@ -16,6 +16,7 @@ mod registry;
use flatten::{rasterize_effects, shadow_coverage};
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use pdf::pdf_export;
pub(crate) use pdf::{check_pdf_options, pdf_report};
pub(crate) use pdf::{flatten_ppi, options, print_pdf};
pub use registry::specs;
+2
View File
@@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
if rasterized > 0 {
r.warnings.push(format!("{rasterized} object(s) went in as images"));
}
// A slide has no room for the overset text either (#1465).
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref()));
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
+288 -36
View File
@@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option<usize> {
s[i..].split_whitespace().next()?.parse().ok()
}
/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole
/// spreads on a sheet. The pages come back in document order.
fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec<usize> {
let Some(st) = s.active() else { return pages.to_vec() };
let d = &st.doc;
let spreads: std::collections::BTreeSet<usize> = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect();
if spreads.is_empty() {
return pages.to_vec();
}
(0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect()
}
pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let r = pdf_export(s, p)?;
let mut r = pdf_export(s, p)?;
// Text the pages have no room for is not in the file: say so, as preflight does (#1465).
// A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as
// well and page 3's overset is as absent from the file as page 2's would be. The selection
// is widened to the spreads it touches before the warnings are collected (noted in review).
let opts = options(p, s.doc()?.doc.page_count())?;
let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() });
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref()));
match str_param(p, "path") {
Some(path) => {
write_file(path, &r.bytes)?;
@@ -169,41 +188,21 @@ pub(crate) fn print_pdf(
/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF
/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it.
pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::ExportReport> {
let st = s.doc()?;
let opts = options(p, st.doc.page_count())?;
pdf_report(&s.doc()?.doc, &s.cache, p)
}
/// [`pdf_export`] of `doc`: `file.package` writes its PDF of the packaged copy with it.
pub(crate) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result<designcraft_pdf::ExportReport> {
let opts = options(p, doc.page_count())?;
let ppi = flatten_ppi("file.exportPdf", p)?;
let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?;
let doc: &designcraft_doc::Document = &st.doc;
let (cat, dest) = view_options(p)?;
let mut r = print_pdf(doc, cache, &opts, ppi)?;
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
// Interactive PDF view options.
let mut cat = String::new();
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/FullScreen");
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/UseOutlines");
}
match str_param(p, "pageLayout") {
Some("single") => cat.push_str("/PageLayout/SinglePage"),
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
None => {}
}
if let Some(v) = str_param(p, "view") {
// The first page opens with this view (pages are objects in order, so refer by index 0).
let dest = match v {
"fitPage" => "/Fit",
"fitWidth" => "/FitH null",
"actual" => "/XYZ null null 1",
o => {
return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)")));
}
};
if let Some(first) = first_page_ref(&r.bytes) {
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
}
let mut cat = cat;
if let Some(dest) = dest
&& let Some(first) = first_page_ref(&r.bytes)
{
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
}
if !cat.is_empty() {
match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) {
@@ -213,8 +212,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
}
// Flip pages every N seconds (presentation).
if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) {
let n = designcraft_pdf::sheet_spreads(doc, &opts).len();
let dur: Vec<Option<String>> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
let dur: Vec<Option<String>> = (0..spreads.len()).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
match designcraft_pdf::add_page_entries(&r.bytes, &dur) {
Some(b) => r.bytes = b,
None => r.warnings.push("page timings couldn't be set".into()),
@@ -232,6 +230,44 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
Ok(r)
}
/// The catalog entries of the interactive view options in `p` (page mode and layout) and the
/// destination the first page opens at (`view`); unknown values are refused.
fn view_options(p: &Value) -> Result<(String, Option<&'static str>)> {
let mut cat = String::new();
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/FullScreen");
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/UseOutlines");
}
match str_param(p, "pageLayout") {
Some("single") => cat.push_str("/PageLayout/SinglePage"),
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
None => {}
}
// The first page opens with this view (pages are objects in order, so refer by index 0).
let dest = match str_param(p, "view") {
None => None,
Some("fitPage") => Some("/Fit"),
Some("fitWidth") => Some("/FitH null"),
Some("actual") => Some("/XYZ null null 1"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
};
Ok((cat, dest))
}
/// Refuses PDF options `p` that [`pdf_report`] would refuse for a document of `pages` pages,
/// without exporting: `file.package` checks them before it writes anything.
pub(crate) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> {
options(p, pages)?;
flatten_ppi("file.exportPdf", p)?;
view_options(p)?;
Ok(())
}
#[cfg(test)]
mod tagged_tests {
use serde_json::json;
@@ -461,3 +497,219 @@ mod page_list_tests {
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
}
}
#[cfg(test)]
mod overset_tests {
use serde_json::json;
use crate::Session;
/// A page-based export leaves out the text its frames have no room for, and said nothing
/// about it: an imported 58-page form lost whole sentences — a data-consent clause, a
/// statutory rule, the last row of two tables — with `warnings` empty while preflight
/// reported 19 overset stories (#1465).
#[test]
fn an_export_warns_about_the_text_the_pages_had_no_room_for() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap();
let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone());
// What preflight says about the same frame, for the warning to agree with.
let issues = s.execute("preflight.run", &json!({})).unwrap();
let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone();
assert_eq!(overset["message"], "Overset text: 76 characters");
for command in ["file.exportPdf", "file.exportPptx"] {
let r = s.execute(command, &json!({})).unwrap();
let warnings = r["warnings"].as_array().expect("warnings").clone();
let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect();
assert_eq!(about.len(), 1, "{command}: {warnings:?}");
let w = about[0];
assert!(w.contains("page 1"), "{command}: the page it is on: {w}");
assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}");
assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}");
}
// The pages being exported are the ones reported on: page 2 has no overset frame.
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
// A reflowable export carries the whole story, so it has nothing to report.
let r = s.execute("file.exportHtml", &json!({})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out");
// `overset` is the index where the text stops fitting; `oversetChars` is how much is left,
// which is the number preflight reports. The two were read as the same thing.
let story = s.execute("story.get", &json!({"story": sid})).unwrap();
assert_eq!(story["overset"], json!(68), "{story}");
assert_eq!(story["oversetChars"], json!(76), "{story}");
// A frame the text fits: nothing is reported anywhere.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap();
for command in ["file.exportPdf", "file.exportPptx"] {
assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}");
}
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}");
}
/// A sheet draws its spread's items without asking which page each one sits on, so a frame
/// straddling two pages is drawn, in part, on both. Choosing the page by the frame's centre
/// alone meant a frame centred on a page the export leaves out had its warning dropped while
/// the part on an exported page went into the file (#1493 review).
#[test]
fn an_export_warns_about_a_frame_that_only_crosses_into_one_of_its_pages() {
let mut s = Session::new();
// Facing pages, so pages 2 and 3 are one spread and a frame can straddle their boundary.
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
// Spread 1 is pages 2-3; its page 2 runs x 0..300 and page 3 runs x 300..600. The frame
// is centred on page 3 and reaches back over the boundary into page 2.
let frame = json!({"spread": 1, "rect": [260, 20, 420, 60], "content": "text", "text": text});
let id = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap();
let about = |s: &mut Session, pages: serde_json::Value| {
let params = if pages.is_null() { json!({}) } else { json!({"pages": pages}) };
let r = s.execute("file.exportPdf", &params).unwrap();
let ws = r["warnings"].as_array().cloned().unwrap_or_default();
ws.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_owned).collect::<Vec<_>>()
};
// The whole document: reported once, at the page the frame is centred on.
let all = about(&mut s, serde_json::Value::Null);
assert_eq!(all.len(), 1, "{all:?}");
assert!(all[0].contains("page 3") && all[0].contains(&format!("frame {id}")), "{}", all[0]);
// Exporting page 3 alone: the centre page, reported as before.
let three = about(&mut s, json!([3]));
assert_eq!(three.len(), 1, "{three:?}");
assert!(three[0].contains("page 3"), "{}", three[0]);
// Exporting page 2 alone: the frame's centre is on page 3, which is not in the export --
// but part of the frame is drawn on page 2, so it is still reported, and the page named is
// one the export has.
let two = about(&mut s, json!([2]));
assert_eq!(two.len(), 1, "a frame crossing into page 2 is reported: {two:?}");
assert!(two[0].contains("page 2"), "and the page it names is an exported one: {}", two[0]);
assert!(two[0].contains(&format!("frame {id}")), "{}", two[0]);
// A page the frame does not reach at all: nothing to say.
assert_eq!(about(&mut s, json!([4])), Vec::<String>::new());
}
/// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript
/// string at it cuts the wrong place once the text is not ASCII (noted in review).
#[test]
fn overset_is_a_byte_offset_into_the_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
// Accented letters are two bytes each, so the byte offset runs ahead of the character index.
let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout.";
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap();
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
let at = story["overset"].as_u64().expect("it oversets") as usize;
assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}");
assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count());
// And `oversetChars` counts characters, which is why the two numbers differ.
assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}");
}
/// A parent page's frame is composed once per page that shows it: its page number marker is
/// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page
/// no exported page uses puts nothing in the file to be missing. Composing it once with no
/// page reported neither: every selection got the same unpaged warning (noted in review).
#[test]
fn a_parent_frame_is_checked_on_the_pages_that_show_it() {
let about = |r: &serde_json::Value| -> Vec<String> {
r["warnings"]
.as_array()
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
.unwrap_or_default()
};
// A parent page that only page 1 uses.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
let frame = s
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text}))
.unwrap();
let id = frame["id"].as_u64().unwrap();
let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap());
assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}");
assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]);
// Page 2 has no parent, so nothing of that frame is in its export to be missing.
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}");
// Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the
// wider line no longer fits. 112 pt is the width where that is the only difference.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap();
let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER);
s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text}))
.unwrap();
for page in [1, 9] {
let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap();
assert!(about(&r).is_empty(), "page {page} fits one digit: {r}");
}
let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap());
assert_eq!(w.len(), 1, "two digits do not fit: {w:?}");
assert!(w[0].contains("page 10"), "{}", w[0]);
// The whole document: one line naming every page it happens on, not one line per page.
let w = about(&s.execute("file.exportPdf", &json!({})).unwrap());
assert_eq!(w.len(), 1, "one line for the story: {w:?}");
assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]);
}
/// A spread export puts the whole spread on the sheet, so choosing one page of it exports its
/// partner too -- and that partner's overset is just as absent from the file. Reporting only
/// the pages that were named left it unmentioned (noted in review of #1465).
#[test]
fn a_spread_export_warns_about_the_pages_it_takes_with_it() {
let mut s = Session::new();
// Facing pages: 2 and 3 share a spread, 1 is the cover on its own.
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
// A frame on page 3, whose text does not fit it. `frame.create` takes a spread and
// spread coordinates, so page 3's own bounds say where to put it.
let (si, b) = {
let d = &s.active().expect("a document").doc;
let (si, pi) = d.page_loc(2).expect("page 3 is there");
(si, d.spreads[si].pages[pi].bounds())
};
let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]);
let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap();
let id = frame["id"].as_u64().unwrap();
let about = |r: &serde_json::Value| -> Vec<String> {
r["warnings"]
.as_array()
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
.unwrap_or_default()
};
// Page by page, choosing page 2 reports nothing: page 2 holds no overset frame.
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}");
// As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported.
let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap();
let w = about(&r);
assert_eq!(w.len(), 1, "the page that came along is reported too: {r}");
assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]);
// And a page on another spread entirely is still not reported.
let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap();
assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}");
}
}
+2 -2
View File
@@ -204,12 +204,12 @@ fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> {
let mut out = Vec::new();
for st in file.objects().into_iter().filter_map(|o| o.into_stream()) {
let d = st.dict();
if !d.get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::<i32>(b"BitsPerComponent") != Some(8) {
if d.get::<Name<'_>>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::<i32>(b"BitsPerComponent") != Some(8) {
continue;
}
let Some(cs) = d.get::<Array<'_>>(b"ColorSpace") else { continue };
let mut it = cs.iter::<Object<'_>>();
if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") {
if it.next().and_then(|o| o.into_name()).is_none_or(|n| n.as_ref() != b"DeviceN") {
continue;
}
let Some(Object::Array(names)) = it.next() else { continue };
+10
View File
@@ -27,6 +27,16 @@ pub fn with_document_extension(path: &str) -> String {
format!("{path}.{}", designcraft_format::EXTENSION)
}
/// `name` without a trailing `.designcraft` (any case): the title of a document opened from bytes
/// under its file name. Save, Save As and the File Name variable add the extension back, so a title
/// that kept it would name the file `Brochure.designcraft.designcraft`. A bare `.designcraft` stays.
pub(super) fn without_native_extension(name: &str) -> &str {
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && ext.eq_ignore_ascii_case(designcraft_format::EXTENSION) => stem,
_ => name,
}
}
/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package.
/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their
/// `mimetype` entry, as both formats write them.
+13 -3
View File
@@ -13,10 +13,11 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
}
/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it.
/// Without a preset or `units`, it is in the default units (Preferences with no document).
fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
let mut nd = match str_param(p, "preset") {
Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
None => NewDocument::default(),
None => s.default_new_document(),
};
apply_settings(&mut nd, p);
check_size("file.new", &nd)?;
@@ -40,12 +41,19 @@ pub(super) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let mut nd = new_document_settings(s, p)?;
s.untitled += 1;
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
let d = new_document(&nd);
let mut d = new_document(&nd);
s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null()));
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}
impl Session {
/// What New Document starts with: the factory settings, in the default units (Preferences
/// with no document open).
pub fn default_new_document(&self) -> NewDocument {
NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() }
}
/// New Document's Preview: a document made as `file.new` would make it with `p`, shown while
/// the New Document window is open. It replaces the previous preview document in its tab and
/// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses
@@ -64,7 +72,9 @@ impl Session {
self.untitled += 1;
}
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
let st = DocState::new(new_document(&nd), None);
let mut d = new_document(&nd);
self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null()));
let st = DocState::new(d, None);
let uid = st.uid;
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
Some((i, d)) => {
+63 -2
View File
@@ -1,6 +1,6 @@
#[cfg(test)]
use super::bytes::{PREVIEW_PX, to_bytes};
use super::bytes::{base64_decode, load_bytes};
use super::bytes::{base64_decode, load_bytes, without_native_extension};
use super::{Arc, DocState, Document, EngineError, Result, Session, Value, bad, json, str_param};
pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> {
@@ -150,7 +150,8 @@ pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let (mut d, warnings) = load_bytes(&b)?;
crate::cmd::datamerge::resolve_sources_on_open(&mut d, None);
if let Some(n) = str_param(p, "name") {
d.title = n.to_string();
// Titled as an IDML file opened from bytes is: without its extension.
d.title = without_native_extension(n).to_string();
}
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i, "warnings": warnings}))
@@ -413,6 +414,66 @@ mod document_fonts_tests {
}
}
#[cfg(test)]
mod open_bytes_tests {
use serde_json::json;
use crate::Session;
#[test]
fn legacy_and_idml_bytes_named_as_indesign_files_open_without_conversion() {
let mut maker = Session::new();
maker.execute("file.new", &json!({})).unwrap();
let idml = designcraft_idml::export_idml(&maker.doc().unwrap().doc);
let base64 = super::super::bytes::base64_encode(&idml);
let legacy = super::super::bytes::base64_encode(&serde_json::to_vec(&maker.doc().unwrap().doc).unwrap());
for name in ["Brochure.indd", "Brochure.INDT"] {
let mut s = Session::new();
s.execute("file.openBytes", &json!({"name": name, "base64": legacy})).unwrap();
assert_eq!(s.documents().len(), 1, "legacy: {name}");
assert_eq!(s.doc().unwrap().doc.title, name, "legacy: {name}");
}
for command in ["file.openBytes", "file.openIdml"] {
for name in ["Brochure.indd", "Brochure.INDT"] {
let mut s = Session::new();
s.execute(command, &json!({"name": name, "base64": base64})).unwrap();
assert_eq!(s.documents().len(), 1, "{command}: {name}");
assert_eq!(s.doc().unwrap().doc.title, "Brochure", "{command}: {name}");
}
}
}
/// A `.designcraft` file opened from bytes under its file name (a browser or host passes the
/// name it has) is titled without the extension, as an IDML file is, so saving it gives
/// `Brochure.designcraft` and not `Brochure.designcraft.designcraft`.
#[test]
fn open_bytes_titles_a_native_document_without_its_extension() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let native = s.execute("file.serialize", &json!({})).unwrap();
for (name, title) in [
("Brochure.designcraft", "Brochure"),
("Brochure.DesignCraft", "Brochure"),
("Brochure", "Brochure"),
("Brochure.v2", "Brochure.v2"),
("Brochure.indd", "Brochure.indd"),
("Brochure.INDT", "Brochure.INDT"),
("Brochure.designcraft.designcraft", "Brochure.designcraft"),
(".designcraft", ".designcraft"),
// Names that aren't ASCII: cutting the extension's length off the end of the last one
// would land inside its `é`.
("Präsentation.designcraft", "Präsentation"),
("日本.DESIGNCRAFT", "日本"),
("é", "é"),
("édesigncraft", "édesigncraft"),
] {
let r = s.execute("file.openBytes", &json!({"name": name, "base64": native["base64"]})).unwrap();
let st = &s.documents()[r["index"].as_u64().unwrap() as usize];
assert_eq!((st.doc.title.as_str(), st.title()), (title, title.to_string()), "{name}");
}
}
}
#[cfg(test)]
mod recent_tests {
use serde_json::json;
+1 -1
View File
@@ -276,7 +276,7 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
}
// Drag: fit proportionally; click: actual size at the point.
let (r, k) = match rect {
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => {
let k = (r.width() / nw).min(r.height() / nh);
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
}
+1 -1
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}",
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing; warningItems (IDML and InDesign files): per warning, the id of the frame it is about, or null}",
always, file_open),
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes),
cmd!(noundo "file.openRecent", "Open Recent File", [], None,
+395 -28
View File
@@ -1,12 +1,12 @@
//! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters,
//! optional formatting in the change, scoped to the document, a story or the selection.
//! a Find Format and a Change Format, scoped to the document, a story or the selection.
use std::ops::Range;
use std::sync::{Arc, Weak};
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
use designcraft_doc::story::floor_char_boundary;
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
@@ -61,18 +61,18 @@ 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, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: 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",
"Change All",
["Edit", "Find/Change"],
None,
"{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}",
"{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text",
has_doc,
change
),
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next),
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next),
]
}
@@ -108,6 +108,227 @@ fn build(p: &Value) -> Result<Grep> {
re.map_err(|e| bad("find", e))
}
/// Find/Change's Find Format: the text a search finds must have it all through.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct FindFormat {
char_style: Option<String>,
para_style: Option<String>,
/// Resolved character attributes, as `type.selectionAttrs` reports them.
chars: serde_json::Map<String, Value>,
}
impl FindFormat {
fn from_params(p: &Value) -> Result<Option<Self>> {
let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) };
let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?;
// A style is named; a null asks for none. Any other value is refused rather than
// searched without.
let name = |k: &str| match o.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) => Ok(Some(n.clone())),
Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))),
};
let chars = match o.get("chars") {
None | Some(Value::Null) => None,
Some(Value::Object(m)) => Some(m),
Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))),
};
// Only attributes text has, kept as the document writes them: `font_style` and
// `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing.
let mut known = designcraft_doc::CharAttrs::default();
for (k, v) in chars.into_iter().flatten() {
known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?;
}
let chars = match serde_json::to_value(&known) {
Ok(Value::Object(m)) => m,
_ => serde_json::Map::new(),
};
let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars };
Ok((f != Self::default()).then_some(f))
}
/// Styles the document doesn't have are refused, as `find.change` refuses them.
fn check(&self, styles: &Styles) -> Result<()> {
if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) {
return Err(bad("find", format!("no character style `{n}`")));
}
if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) {
return Err(bad("find", format!("no paragraph style `{n}`")));
}
Ok(())
}
/// The text in `r` of `story` that has the format, relative to `r.start`: the longest
/// stretches, each paragraph's apart (a paragraph's `\n` isn't in one).
fn spans(&self, styles: &Styles, story: &Story, r: &Range<usize>) -> Vec<Range<usize>> {
let mut out: Vec<Range<usize>> = Vec::new();
// Paragraphs and runs both go in text order: the runs before a paragraph are passed once.
let runs: Vec<_> = story.runs().collect();
let mut first = 0;
for (pi, pr) in story.para_ranges().into_iter().enumerate() {
let (a, b) = (pr.start.max(r.start), pr.end.min(r.end));
let Some(pf) = story.paras.get(pi) else { continue };
if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) {
continue;
}
while runs.get(first).is_some_and(|(run, _)| run.end <= a) {
first += 1;
}
let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1);
for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) {
let (x, y) = (run.start.max(a), run.end.min(b));
if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) {
continue;
}
if let Some(base) = &base {
let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default();
if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) {
continue;
}
}
match out.last_mut() {
Some(last) if last.end == x - r.start => last.end = y - r.start,
_ => out.push(x - r.start..y - r.start),
}
}
}
out
}
}
/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest
/// exactly.
fn same_value(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() < 1e-6,
_ => a == b,
},
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)),
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))),
_ => a == b,
}
}
/// Whether match `m` lies in one of `spans`.
fn within(spans: &[Range<usize>], m: &Range<usize>) -> bool {
spans.iter().any(|s| s.start <= m.start && m.end <= s.end)
}
/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all
/// through), or, with no pattern, the stretches of text with the Find Format.
struct Search {
re: Option<Grep>,
format: Option<FindFormat>,
}
/// The search of a Find/Change request: an empty `find` needs a Find Format.
fn search(p: &Value) -> Result<Search> {
let format = FindFormat::from_params(p)?;
if format.is_some() && str_param(p, "find") == Some("") {
return Ok(Search { re: None, format });
}
Ok(Search { re: Some(build(p)?), format })
}
impl Search {
fn check(&self, styles: &Styles) -> Result<()> {
self.format.as_ref().map_or(Ok(()), |f| f.check(styles))
}
/// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any;
/// `None`: no Find Format).
fn spans(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Option<Vec<Range<usize>>> {
let f = self.format.as_ref()?;
Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default())
}
/// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`).
fn find_all(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> {
let spans = self.spans(d, sid, cell, r);
let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) };
let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) };
Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect())
}
/// The first match in `text` (target `r` of story `sid`) at or after byte `from`.
fn find_at(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> {
let spans = self.spans(d, sid, cell, r);
let Some(re) = &self.re else {
// From inside a stretch, the rest of it.
return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end));
};
let mut at = from;
loop {
let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) };
if spans.as_ref().is_none_or(|s| within(s, &m)) {
return Ok(Some(m));
}
// On past it: its end, or a character on from an empty match.
at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end };
if at > text.len() {
return Ok(None);
}
}
}
/// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story
/// `sid`) is what Find Next found there, so the two commands agree on what a match is. With
/// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest
/// of a stretch from inside it). With a pattern: the match it makes from the selection's start
/// must be the selection, overlapping matches Find Next can select included.
#[allow(clippy::too_many_arguments)]
fn found_edit(
&self,
d: &Document,
sid: StoryId,
cell: Option<CellAddr>,
r: &Range<usize>,
text: &str,
range: &Range<usize>,
rep: &str,
grep: bool,
) -> Result<Option<(Range<usize>, String)>> {
let spans = self.spans(d, sid, cell, r);
let Some(re) = &self.re else {
let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end);
return Ok(inside.then(|| (range.clone(), rep.to_string())));
};
let found =
if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) };
let found = found.map_err(|e| bad("find.change", e))?;
Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m))))
}
/// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each
/// GREP match, else `rep` itself.
#[allow(clippy::too_many_arguments)]
fn replacements(
&self,
d: &Document,
sid: StoryId,
cell: Option<CellAddr>,
r: &Range<usize>,
text: &str,
rep: &str,
grep: bool,
first_only: bool,
) -> Result<Vec<(Range<usize>, String)>> {
let limit = if first_only { 1 } else { usize::MAX };
match (&self.re, self.spans(d, sid, cell, r)) {
(Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)),
(Some(re), Some(spans)) if grep => Ok(re
.replacements(text, rep, false)
.map_err(|e| bad("find.change", e))?
.into_iter()
.filter(|(m, _)| within(&spans, m))
.take(limit)
.collect()),
_ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Domain {
Document,
@@ -140,6 +361,7 @@ struct Matcher {
grep: bool,
case_sensitive: bool,
whole_word: bool,
format: Option<FindFormat>,
}
impl Matcher {
@@ -149,6 +371,7 @@ impl Matcher {
grep: bool_or(p, "grep", false),
case_sensitive: bool_or(p, "caseSensitive", false),
whole_word: bool_or(p, "wholeWord", false),
format: FindFormat::from_params(p).ok().flatten(),
}
}
}
@@ -183,6 +406,29 @@ impl FindContext {
self.selection = st.selection.clone();
}
/// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the
/// changed text and the search goes on after it.
fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option<CellAddr>, old: &Range<usize>, new_len: usize) {
let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x };
for (tid, tcell, r) in &mut self.targets {
if *tid == sid && *tcell == cell {
*r = moved(r.start)..moved(r.end);
}
}
self.offset = old.start + new_len;
self.last_match_end = Some(self.offset);
let at = self.offset;
if let Some(i) = self
.order
.iter()
.position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end))
{
self.target = i;
}
self.document = Arc::downgrade(&st.doc);
self.acknowledge(st);
}
fn restart(&mut self, st: &DocState) -> Result<()> {
self.target = 0;
self.last_match_end = None;
@@ -299,13 +545,14 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
const MAX_MATCHES: usize = 10_000;
fn find(s: &mut Session, p: &Value) -> Result<Value> {
let re = build(p)?;
let re = search(p)?;
re.check(&s.doc()?.doc.styles)?;
let context = scope(s, p)?;
let st = s.doc()?;
let mut out = Vec::new();
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))? {
for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? {
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});
@@ -349,51 +596,143 @@ fn char_overrides(attrs: Option<&Value>) -> Result<designcraft_doc::CharAttrs> {
}
fn change(s: &mut Session, p: &Value) -> Result<Value> {
let re = build(p)?;
let grep = bool_or(p, "grep", false);
let rep = replacement(p);
let re = search(p)?;
re.check(&s.doc()?.doc.styles)?;
// With no pattern (a Find Format alone), the change is literal text.
let grep = bool_or(p, "grep", false) && re.re.is_some();
let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) };
let first_only = bool_or(p, "first", false);
let attrs = p.get("attrs").cloned();
let over = char_overrides(attrs.as_ref())?;
// A null attribute clears that override on the found text.
let cleared: Vec<String> = attrs
.as_ref()
.and_then(Value::as_object)
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
.unwrap_or_default();
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
let targets = scope(s, p)?.targets;
s.edit(|d, sel| {
let char_style = str_param(p, "charStyle").map(str::to_string);
let para_style = str_param(p, "paraStyle").map(str::to_string);
let styles = &s.doc()?.doc.styles;
if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) {
return Err(bad("find.change", format!("no character style `{n}`")));
}
if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) {
return Err(bad("find.change", format!("no paragraph style `{n}`")));
}
// InDesign: with a Change Format and nothing in Change to, the found text is only formatted.
let format_only =
str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty());
let context = scope(s, p)?;
let targets = context.targets.clone();
// Change: only the text Find Next found, the selection, while it is still a match (as Find
// Next defines one), with its replacement.
let (found, found_out) = if bool_or(p, "found", false) {
let st = s.doc()?;
let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range()));
let hit = selected.and_then(|(sid, cell, range)| {
targets.iter().find_map(|(tid, tcell, r)| {
if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end {
return None;
}
let text = target_text(&st.doc, *tid, cell, r).ok()?;
let local = range.start - r.start..range.end - r.start;
let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??;
Some(((sid, cell, r.start + m.start..r.start + m.end), out))
})
});
match hit {
Some((hit, out)) => (Some(hit), Some(out)),
None => return Ok(json!({"count": 0})),
}
} else {
(None, None)
};
let (count, changed) = s.edit(|d, sel| {
let styles = d.styles.clone();
let mut count = 0usize;
let mut changed = None;
for (sid, cell, r) in targets {
if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) {
continue;
}
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 {
re.replacements(text, &rep, first_only)
} else {
re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect())
}
.map_err(|e| bad("find.change", e))?
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
let edits: Vec<(std::ops::Range<usize>, String)> = match (&found, &found_out) {
// Change: the found text alone, in the target it lies in.
(Some((_, _, range)), Some(out)) => {
if r.start <= range.start && range.end <= r.end {
vec![(range.clone(), out.clone())]
} else {
Vec::new()
}
}
_ => re
.replacements(d, sid, cell, &r, text, &rep, grep, first_only)?
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect(),
};
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() {
st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over));
let changed = if format_only {
range.clone()
} else {
st.replace(range.clone(), new);
range.start..range.start + new.len()
};
// A paragraph style goes to the match's paragraphs, an empty match's (`^`) too;
// character formatting needs characters.
if let Some(n) = &para_style {
super::style::apply_para_style(st, &styles, n, &changed, false);
}
if !changed.is_empty() {
if let Some(n) = &char_style {
super::style::apply_char_style(st, &styles, n, &changed);
}
if !over.is_empty() || !cleared.is_empty() {
st.format_chars(changed, |f| {
f.over.merge(&over);
for k in &cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
}
count += 1;
}
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
repair_selection(t, st);
}
// Change leaves the changed text selected.
if let Some((_, _, range)) = &found
&& let Some((_, new)) = edits.first()
&& let Some(t) = sel.text.as_mut()
{
let len = if format_only { range.len() } else { new.len() };
(t.anchor, t.focus) = (range.start, range.start + len);
changed = Some(len);
}
// `first` is the first match of the whole search, not of each story.
if first_only && count > 0 {
break;
}
}
Ok(json!({"count": count}))
})
Ok((count, changed))
})?;
// Change: Find Next goes on after the changed text, in the search's domain.
if let (Some((sid, cell, old)), Some(len)) = (&found, changed) {
let mut context = context;
context.after_change(s.doc()?, *sid, *cell, old, len);
s.find_context = Some(context);
}
Ok(json!({"count": count}))
}
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
let re = build(p)?;
let re = search(p)?;
re.check(&s.doc()?.doc.styles)?;
let mut context = scope(s, p)?;
let st = s.doc()?;
let mut hit = None;
@@ -403,7 +742,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
if text.get(from..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
}
if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? {
if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? {
let (a, b) = (r.start + m.start, r.start + m.end);
// Match find_iter/captures_iter: suppress an empty match adjacent to the
// previous match's end, but keep adjacent nonempty matches.
@@ -489,6 +828,34 @@ mod object_find_tests {
use crate::Session;
/// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style:
/// only character formatting needs characters.
#[test]
fn change_format_styles_paragraphs_of_empty_matches() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s
.execute(
"frame.create",
&json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One
Two
Three"}),
)
.unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap();
assert_eq!(r["count"], 3);
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::<Vec<_>>());
assert_eq!(
story.text,
"One
Two
Three"
);
}
#[test]
fn find_and_change_objects_by_attributes() {
let mut s = Session::new();
+212
View File
@@ -621,6 +621,27 @@ fn document_scope_searches_and_changes_table_cells() {
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
}
/// Change (`found`) on text Find Next found in a table cell changes it there, leaves the
/// same text in the story alone, and Find Next goes on after it.
#[test]
fn change_changes_the_text_find_next_found_in_a_cell() {
let (mut s, sid) = scope_session("red 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": "red"})).unwrap();
let p = json!({"find": "red", "change": "blue", "scope": "document"});
let hit = s.execute("find.next", &p).unwrap();
assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}");
let mut change = p.clone();
change["found"] = json!(true);
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
let t = s.execute("table.get", &json!({"table": table})).unwrap();
assert_eq!(t["cells"][1]["text"], "blue");
assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays");
let next = s.execute("find.next", &p).unwrap();
assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}");
}
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);
@@ -747,3 +768,194 @@ fn find_scope_revert_releases_context_with_same_document_uid() {
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red");
assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range");
}
#[test]
fn change_format_styles_the_found_text_and_keeps_it() {
// The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with
// Change to empty, so the words stay.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = "Poached Eggs\nBreak eggs; cook until eggs are set.";
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap();
assert_eq!(r["count"], 2);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone();
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(story.text, text, "the words stay");
for at in [19, 36] {
assert_eq!(&story.text[at..at + 4], "eggs");
assert_eq!(style_at(&s, at), "eggs");
}
assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\"");
assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text");
// A paragraph style goes to the found text's paragraphs, with attributes over the text.
s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap();
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method"));
assert_eq!(story.format_after(25).over.underline, Some(true));
assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style");
// Change to and a Change Format: the new text is formatted.
s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap();
assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs");
// No such style: refused, nothing changed.
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err());
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
// A null attribute is a Change Format too: the found text stays and loses that override.
let before = s.doc().unwrap().doc.story(sid).unwrap().clone();
s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap();
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(story.text, before.text, "the words stay");
assert_eq!(story.format_after(25).over.underline, None);
// With no Change Format, an empty Change to still deletes the found text.
s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap();
assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break"));
}
#[test]
fn a_find_format_finds_only_text_with_it() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r =
s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
for (a, b) in [(6, 10), (12, 16)] {
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap();
}
s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap();
let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> {
let m = s.execute("find.find", &p).unwrap();
m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect()
};
// "eggs" where it has the character style: the first only.
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]);
// Nothing in Find what: each stretch of text in the style.
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]);
// A paragraph style, and resolved attributes.
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]);
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]);
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart");
// Find Next walks the stretches.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}});
assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6);
assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12);
// Equivalent spellings the attributes accept find the same text: a snake_case key, and a
// nested number written as an integer (14 pt leading on the second paragraph).
s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap();
s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap();
for (a, b) in [
(json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})),
(json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})),
] {
let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}}));
let all = found(&mut s, a.clone());
assert!(!all.is_empty(), "{a}");
assert_eq!(found(&mut s, b.clone()), all, "{b}");
for p in [&a, &b] {
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}");
}
}
// A null attribute asks for nothing.
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]);
// With a Change Format: the text in the style goes back to [None], underlined.
let r = s
.execute(
"find.change",
&json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}),
)
.unwrap();
assert_eq!(r["count"], 2, "a snake_case attribute changes the same text");
assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty());
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]);
// Unknown styles and attributes are refused; an empty search needs a Find Format.
assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err());
assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err());
assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err());
// Malformed fields are refused, not searched without.
for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] {
assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}");
}
assert!(s.execute("find.find", &json!({"find": ""})).is_err());
}
/// Change and Change/Find: only the text Find Next found changes; it stays selected and the
/// search goes on after it in its domain (here the original selection). A selection that
/// isn't a match changes nothing and makes no undo step.
#[test]
fn change_changes_the_found_text_and_the_search_goes_on() {
let (mut s, sid) = scope_session("red red | red");
scope_select(&mut s, sid, 0, 7);
let p = json!({"find": "red", "change": "blue", "scope": "selection"});
let mut change = p.clone();
change["found"] = json!(true);
scope_hit(&mut s, &p, sid, 0, 3);
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone();
assert_eq!(text(&s), "blue red | red");
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected");
scope_hit(&mut s, &p, sid, 5, 8);
s.execute("find.change", &change).unwrap();
assert_eq!(text(&s), "blue blue | red");
assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched");
// Not a match: nothing changes, no undo step.
scope_select(&mut s, sid, 0, 4);
let history = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
// A Change Format with nothing in Change to formats the found text only.
scope_select(&mut s, sid, 0, 0);
let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}});
let hit = s.execute("find.next", &fmt).unwrap();
assert_eq!(hit["start"], 12);
let mut found = fmt.clone();
found["found"] = json!(true);
s.execute("find.change", &found).unwrap();
assert_eq!(text(&s), "blue blue | red");
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true));
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15);
}
/// Change accepts what Find Next selected even when Change All wouldn't match there: the
/// rest of a Find Format stretch from a caret inside it, and an overlapping match.
#[test]
fn change_takes_any_selection_find_next_made() {
// A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it.
let (mut s, sid) = scope_session("plain bold text");
s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap();
scope_select(&mut s, sid, 6, 10);
s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap();
scope_select(&mut s, sid, 8, 8);
let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"});
let hit = s.execute("find.next", &p).unwrap();
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10)));
let mut change = p.clone();
change["found"] = json!(true);
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest");
// "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds.
for grep in [false, true] {
let (mut s, sid) = scope_session("aaa");
scope_select(&mut s, sid, 1, 1);
let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") };
let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"});
let hit = s.execute("find.next", &p).unwrap();
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3)));
let mut found = p.clone();
found["found"] = json!(true);
assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}");
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a<a>" } else { "ab" });
}
}
+71 -1
View File
@@ -14,7 +14,7 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| {
cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle, fsType, restrictedLicence, bitmapOnly, noSubsetting, editable, previewPrint (the OS/2 fsType embedding bits of the face that sets the text and what they mean; null when missing or the face has no OS/2 table)}] (missing first)", has_doc, |s, _| {
Ok(Value::Array(list(&s.doc()?.doc)))
}),
cmd!(
@@ -75,6 +75,19 @@ pub(super) struct UsedFont {
pub resolved_style: String,
pub replacement_family: String,
pub replacement_style: String,
/// The OS/2 `fsType` of the face that sets the text (`resolved_*`); None when missing or the
/// face has no OS/2 table (as are the bits below).
pub fs_type: Option<u16>,
/// Restricted License embedding (bit 1 without bits 2–3).
pub restricted_licence: Option<bool>,
/// Bitmap embedding only (bit 9).
pub bitmap_only: Option<bool>,
/// No subsetting (bit 8): the font may only be embedded whole.
pub no_subsetting: Option<bool>,
/// Editable embedding (bit 3).
pub editable: Option<bool>,
/// Preview & Print embedding (bit 2).
pub preview_print: Option<bool>,
}
fn list(d: &Document) -> Vec<Value> {
@@ -124,6 +137,8 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
let resolved = db.face(&family, &style);
let characters: Vec<char> = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
let replacement = db.replacement_face(&family, &style, &characters);
let fs_type = (!missing).then(|| resolved.fs_type()).flatten();
let bit = |mask: u16| fs_type.map(|t| t & mask != 0);
let source = (!missing).then(|| match db.face(&family, &style).source {
FontSource::Bundled => "bundled",
FontSource::Installed(_) => "installed",
@@ -148,6 +163,12 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
resolved_style: resolved.style.clone(),
replacement_family: replacement.family.clone(),
replacement_style: replacement.style.clone(),
fs_type,
restricted_licence: fs_type.map(|_| resolved.restricted_licence()),
bitmap_only: bit(0x0200),
no_subsetting: bit(0x0100),
editable: bit(0x0008),
preview_print: bit(0x0004),
}
})
.collect();
@@ -297,6 +318,55 @@ mod tests {
assert_eq!(before, s.execute("font.list", &json!({})).unwrap());
}
/// `font.list` reports each face's OS/2 fsType and what its bits allow, so a caller can
/// refuse a face whose licence doesn't allow the export it is about to make.
#[test]
fn font_list_reports_the_embedding_licence_bits() {
use designcraft_fonts::testing::{font_with, with_fs_type, with_table_len};
const FAMILY: &str = "DocFont List FsType";
const FS_TYPES: [u16; 3] = [0x0002, 0x0108, 0x0204];
for fs_type in FS_TYPES {
designcraft_fonts::FontDb::global()
.add_font(with_fs_type(font_with(&format!("{FAMILY} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap());
}
// An OS/2 table cut to nothing can't be read: as if the font had none.
designcraft_fonts::FontDb::global().add_font(with_table_len(font_with(&format!("{FAMILY} no OS/2"), &['W']).unwrap(), b"OS/2", 0).unwrap());
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// "W W W W Plain Gone": each W in one of the fonts above, Plain in the default (bundled)
// font, Gone in a family that isn't available.
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "W W W W Plain Gone"})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let set = |s: &mut Session, at: usize, end: usize, family: &str| {
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": end})).unwrap();
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
};
for (i, fs_type) in FS_TYPES.into_iter().enumerate() {
set(&mut s, i * 2, i * 2 + 1, &format!("{FAMILY} {fs_type:04x}"));
}
set(&mut s, 6, 7, &format!("{FAMILY} no OS/2"));
set(&mut s, 14, 18, "Nonexistent Sans");
let l = s.execute("font.list", &json!({})).unwrap();
let rows = l.as_array().unwrap();
let entry = |family: &str| rows.iter().find(|f| f["family"] == family).unwrap_or_else(|| panic!("{family}: {l}"));
let bits = |e: &Value| ["fsType", "restrictedLicence", "bitmapOnly", "noSubsetting", "editable", "previewPrint"].map(|k| e[k].clone());
let (t, f) = (json!(true), json!(false));
assert_eq!(entry(&format!("{FAMILY} 0002"))["source"], "added", "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} 0002"))), [json!(0x0002), t.clone(), f.clone(), f.clone(), f.clone(), f.clone()], "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} 0108"))), [json!(0x0108), f.clone(), f.clone(), t.clone(), t.clone(), f.clone()], "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} 0204"))), [json!(0x0204), f.clone(), t.clone(), f.clone(), f.clone(), t.clone()], "{l}");
// The bundled fonts are OFL: installable embedding, every bit clear.
let bundled = rows.iter().find(|r| r["source"] == "bundled").unwrap_or_else(|| panic!("no bundled face: {l}"));
assert_eq!(bits(bundled), [json!(0), f.clone(), f.clone(), f.clone(), f.clone(), f], "{l}");
// A face without an OS/2 table doesn't say what its licence allows.
assert_eq!(entry(&format!("{FAMILY} no OS/2"))["missing"], false, "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} no OS/2"))), [(); 6].map(|()| Value::Null), "{l}");
// A missing face has no file to read.
let missing = entry("Nonexistent Sans");
assert_eq!(missing["missing"], true, "{l}");
assert_eq!(bits(missing), [(); 6].map(|()| Value::Null), "{l}");
}
#[test]
fn find_and_replace_font() {
let mut s = Session::new();
+2 -1
View File
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
/// Register commands for editing hyperlinks and manual bookmarks.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -73,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").map(str::to_string);
s.edit(|d, _| {
let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1));
d.bookmarks.push(Bookmark { name, page, children: vec![] });
d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false });
Ok(json!({"index": d.bookmarks.len() - 1}))
})
}),
+50 -16
View File
@@ -160,7 +160,7 @@ pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result<Documen
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
pub fn import_report(bytes: &[u8], package: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
import_report_with_previews(bytes, package, &std::collections::HashMap::new())
import_report_with_previews(bytes, package, &std::collections::HashMap::new()).map(|(document, warnings, _)| (document, warnings))
}
/// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read.
@@ -168,7 +168,7 @@ fn import_report_with_previews(
bytes: &[u8],
package: Option<&std::path::Path>,
previews: &std::collections::HashMap<String, std::sync::Arc<Vec<u8>>>,
) -> Result<(Document, Vec<String>)> {
) -> Result<(Document, Vec<String>, std::collections::HashMap<String, designcraft_doc::StoryId>)> {
#[cfg(not(target_arch = "wasm32"))]
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
let read = |link: &str| -> Option<Vec<u8>> {
@@ -184,7 +184,7 @@ fn import_report_with_previews(
None
}
};
let designcraft_idml::Imported { mut document, warnings } =
let designcraft_idml::Imported { mut document, warnings, story_ids } =
designcraft_idml::import_idml_report_with_previews(bytes, &read, previews).map_err(|e| EngineError::Other(e.to_string()))?;
resolve_pdf_crops(&mut document);
#[cfg(not(target_arch = "wasm32"))]
@@ -199,7 +199,7 @@ fn import_report_with_previews(
resolve_packaged_links(&mut document, package);
document
};
Ok((document, warnings))
Ok((document, warnings, story_ids))
}
/// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page,
@@ -422,7 +422,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
} else {
bytes
};
let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?;
let (mut d, import_warnings, story_ids) = import_report_with_previews(&bytes, package.as_deref(), &previews)?;
warnings.extend(import_warnings);
if !title.is_empty() {
d.title = title;
@@ -433,17 +433,33 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
};
warnings.extend(font_warnings);
warnings.extend(super::style::grep_style_warnings(&d));
let warning_items: Vec<Value> = warnings.iter().map(|w| warning_item(&d, &story_ids, w).map_or(Value::Null, |i| json!(i.0))).collect();
// Never save over the .idml or .indd with the native format: the document starts unsaved.
let mut st = DocState::new(d, None);
st.fonts = fonts;
let i = s.add_document(st);
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings, "warningItems": warning_items}))
}
/// The frame an import warning is about, so it can be shown: the first frame of the story a
/// "story …: …" warning names, by the converter's INDD id (`story 30892`, which it writes to the
/// IDML as `u78ac`) or by its IDML `Self` (`story u78ac`).
fn warning_item(
d: &Document,
story_ids: &std::collections::HashMap<String, designcraft_doc::StoryId>,
warning: &str,
) -> Option<designcraft_doc::ItemId> {
let name = warning.strip_prefix("story ")?.split([':', ' ']).next()?;
let self_id = name.parse::<u32>().map_or_else(|_| name.to_string(), |uid| format!("u{uid:x}"));
d.stories.get(story_ids.get(&self_id)?)?.frames.first().copied()
}
/// An InDesign document or template (.indd, .indt): known by its signature, or by its name when
/// the bytes are something else (the converter then says why it can't read them).
/// the bytes are neither a recognized DesignCraft document nor IDML. The converter explains
/// unrecognized data with an InDesign extension.
pub(crate) fn is_indd(name: &str, bytes: &[u8]) -> bool {
bytes.starts_with(&indd::header::SIGNATURE) || has_indd_extension(name)
bytes.starts_with(&indd::header::SIGNATURE)
|| (has_indd_extension(name) && !super::file::is_document(bytes) && !bytes.trim_ascii_start().starts_with(b"{"))
}
pub(crate) fn has_indd_extension(name: &str) -> bool {
@@ -479,6 +495,24 @@ mod tests {
Ok((bytes, png))
}
#[test]
fn story_warnings_point_at_the_story_frame() {
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
let idml = designcraft_idml::export_idml_with(&session.doc().unwrap().doc, &designcraft_idml::ExportOptions { embed_images: true });
let (d, _, story_ids) = import_report_with_previews(&idml, None, &std::collections::HashMap::new()).unwrap();
let (self_id, sid) = story_ids.iter().find(|(_, sid)| d.stories.get(*sid).is_some_and(|s| !s.frames.is_empty())).unwrap();
let frame = d.stories[sid].frames[0];
assert_eq!(warning_item(&d, &story_ids, &format!("story {self_id}: something")), Some(frame));
// The INDD converter names stories by their number, which it writes as `u` + hex.
if let Some(uid) = self_id.strip_prefix('u').and_then(|h| u32::from_str_radix(h, 16).ok()) {
assert_eq!(warning_item(&d, &story_ids, &format!("story {uid}: text orientation of its frames is not known")), Some(frame));
}
assert_eq!(warning_item(&d, &story_ids, "story 999999999: unknown"), None);
assert_eq!(warning_item(&d, &story_ids, "a table of 2 rows was read as 1"), None);
}
#[test]
fn opens_nested_packaged_links_from_a_different_computer() {
let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id()));
@@ -493,7 +527,7 @@ mod tests {
assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists");
let resolved = std::path::absolute(&path).unwrap();
assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file");
assert_eq!(super::super::links::status(asset, None), "ok");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -510,7 +544,7 @@ mod tests {
let asset = doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png);
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
assert_eq!(super::super::links::status(asset, None), "ok");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -526,7 +560,7 @@ mod tests {
let asset = doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png);
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
assert_eq!(super::super::links::status(asset, None), "ok");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -546,7 +580,7 @@ mod tests {
let asset = doc.assets.values().next().unwrap();
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
assert_eq!(*asset.data, png);
assert_eq!(super::super::links::status(asset, None), "modified");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified");
std::fs::remove_file(&original).unwrap();
std::fs::write(&copy, b"modified packaged copy").unwrap();
@@ -554,7 +588,7 @@ mod tests {
let asset = doc.assets.values().next().unwrap();
assert_eq!(asset.link.as_deref(), copy.to_str());
assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
assert_eq!(super::super::links::status(asset, None), "modified");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified");
std::fs::remove_file(&copy).unwrap();
let packaged = package.join("x.idml");
@@ -563,7 +597,7 @@ mod tests {
let asset = doc.assets.values().next().unwrap();
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
assert_eq!(*asset.data, png);
assert_eq!(super::super::links::status(asset, None), "missing");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing");
}
let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty());
@@ -611,7 +645,7 @@ mod tests {
assert!(seen.iter().all(|p| p.starts_with(&dir) || itself.as_ref() == Some(p)), "{link}: {seen:?}");
let asset = doc.assets.values().next().unwrap();
assert!(asset.data.is_empty(), "{link}");
assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing", "{link}");
}
// An image elsewhere on this computer is read, and so is one beside the package.
let away = outside.join("logo.png");
@@ -620,7 +654,7 @@ mod tests {
let doc = import(&idml, Some(&package)).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str()));
assert_eq!(super::super::links::status(asset, None), "ok");
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap();
let (idml, _) = linked_idml("Links/logo.png", false).unwrap();
let doc = import(&idml, Some(&package)).unwrap();
+4
View File
@@ -20,3 +20,7 @@ pub use sections::chapter_numbering;
#[cfg(test)]
#[path = "parent_delete_tests.rs"]
mod parent_delete_tests;
#[cfg(test)]
#[path = "parent_resize_tests.rs"]
mod parent_resize_tests;
+129 -75
View File
@@ -61,84 +61,93 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w
/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place:
/// the page grows away from it, so objects keep their place relative to it.
pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>, anchor: usize) {
for page in pages {
let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue };
resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor);
}
}
pub(super) fn resize_spread_page(
d: &mut designcraft_doc::Document,
sr: designcraft_doc::SpreadRef,
pi: usize,
w: Option<f64>,
h: Option<f64>,
anchor: usize,
) {
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0);
{
for page in pages {
let Some((si, pi)) = d.page_loc(page - 1) else { continue };
let sp = std::sync::Arc::make_mut(&mut d.spreads[si]);
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
let old_bounds = sp.pages[pi].bounds();
// The page's own objects (outer pages take the pasteboard beside them).
let last = sp.pages.len() - 1;
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 });
let own: Vec<usize> = sp
.items
.iter()
.enumerate()
.filter(|(_, it)| {
let c = it.bounds().center().x;
c >= lo && c < hi
})
.map(|(i, _)| i)
.collect();
let pg = &mut sp.pages[pi];
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
if let Some(w) = w {
set_page_width(pg, w.max(1.0));
}
if let Some(h) = h {
pg.height = h.max(1.0);
}
let left = pg.side == designcraft_doc::PageSide::Left;
sp.relayout();
// Keep the spine: shift the whole spread back when a left page grows.
if left && dw != 0.0 {
for q in &mut sp.pages {
q.x -= dw;
}
}
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
// Objects follow their page.
let shifts: Vec<(f64, f64, f64)> =
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
for it in &mut sp.items {
let cx = it.bounds().center().x;
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
&& *dx != 0.0
{
let it = std::sync::Arc::make_mut(it);
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
}
}
// Page guides move with their page.
for (k, q) in sp.pages.iter_mut().enumerate() {
let dx = new[k] - old[k];
if dx != 0.0 {
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
}
}
}
// Growing away from the anchor: the page's objects and guides keep their place
// relative to it.
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
if shift != designcraft_geom::Vec2::ZERO {
for i in &own {
if let Some(it) = sp.items.get_mut(*i) {
let it = std::sync::Arc::make_mut(it);
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
}
}
for g in &mut sp.pages[pi].guides {
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
}
}
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved);
let Some(sp) = d.spread_mut(sr) else { return };
let origin = sp.bounds().x0;
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
let old_bounds = sp.pages[pi].bounds();
// The page's own objects (outer pages take the pasteboard beside them).
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { f64::INFINITY } else { old_bounds.x1 });
let own: Vec<usize> = sp
.items
.iter()
.enumerate()
.filter(|(_, it)| {
let c = it.bounds().center().x;
c >= lo && c < hi
})
.map(|(i, _)| i)
.collect();
let pg = &mut sp.pages[pi];
let dw = w.map_or(0.0, |w| w - pg.width);
let dh = h.map_or(0.0, |h| h - pg.height);
if let Some(w) = w {
set_page_width(pg, w);
}
if let Some(h) = h {
pg.height = h;
}
let left = pg.side == designcraft_doc::PageSide::Left;
sp.relayout();
// Relayout resets the origin; retain prior edits and grow left pages away from the spine.
let offset = origin - if left { dw } else { 0.0 };
for q in &mut sp.pages {
q.x += offset;
}
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
// Objects follow their page.
let shifts: Vec<(f64, f64, f64)> =
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
for it in &mut sp.items {
let cx = it.bounds().center().x;
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
&& *dx != 0.0
{
let it = std::sync::Arc::make_mut(it);
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
}
}
// Page guides move with their page.
for (k, q) in sp.pages.iter_mut().enumerate() {
let dx = new[k] - old[k];
if dx != 0.0 {
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
}
}
}
// Growing away from the anchor: the page's objects and guides keep their place
// relative to it.
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
if shift != designcraft_geom::Vec2::ZERO {
for i in &own {
if let Some(it) = sp.items.get_mut(*i) {
let it = std::sync::Arc::make_mut(it);
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
}
}
for g in &mut sp.pages[pi].guides {
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
}
}
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
crate::cmd::liquid::apply(d, sr, pi, moved);
}
/// File › Document Setup values.
@@ -322,6 +331,51 @@ mod setup_tests {
mod page_size_tests {
use serde_json::json;
#[test]
fn document_page_resize_preserves_shifted_spread_objects_and_guides() {
for binding in ["leftToRight", "rightToLeft"] {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": true, "width": 400, "height": 400})).unwrap();
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
let before = s.doc().unwrap().doc.clone();
let (si, _) = before.page_loc(1).unwrap();
let (left, right) = before.spreads[si].outer_page_indices().unwrap();
let mut items = Vec::new();
for pi in [left, right] {
let x = before.spreads[si].pages[pi].x;
let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 80.0, 80, x + 180.0, 180]})).unwrap()["id"].as_u64().unwrap();
items.push(designcraft_doc::ItemId(id));
s.execute("guide.add", &json!({"spread": si, "page": pi, "orientation": "vertical", "position": x + 120.0})).unwrap();
}
let first = before.first_page_of_spread(si) + 1;
s.execute("layout.pageSize", &json!({"pages": [first + left], "width": 300})).unwrap();
let shifted = s.doc().unwrap().doc.clone();
let sp = &shifted.spreads[si];
assert_eq!(sp.bounds().x0, 100.0);
assert_eq!(sp.pages[right].x, 400.0);
assert_eq!(sp.pages[left].guides[0].position, 220.0);
assert_eq!(sp.pages[right].guides[0].position, 520.0);
assert_eq!(shifted.item(items[0]).unwrap().bounds().x0, 180.0);
assert_eq!(shifted.item(items[1]).unwrap().bounds().x0, 480.0);
for width in [500, 450, 520] {
s.execute("layout.pageSize", &json!({"pages": [first + right], "width": width})).unwrap();
let d = &s.doc().unwrap().doc;
let sp = &d.spreads[si];
assert_eq!(sp.bounds().x0, 100.0, "{binding}, width {width}");
assert_eq!(sp.pages[right].x, 400.0);
for pi in [left, right] {
assert_eq!(sp.pages[pi].guides, shifted.spreads[si].pages[pi].guides);
}
for id in &items {
assert_eq!(d.item(*id).unwrap().bounds(), shifted.item(*id).unwrap().bounds());
}
assert_eq!(d.parents, before.parents);
assert_eq!(d.settings, before.settings);
d.check().unwrap();
}
}
}
#[test]
fn page_size_per_page_keeps_objects_on_their_page() {
let mut s = crate::Session::new();
+83
View File
@@ -65,6 +65,43 @@ pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result<Value>
})
}
pub(super) fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?;
let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?;
let pi = p
.get("page")
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.filter(|i| *i < d.parents[si].pages.len())
.ok_or_else(|| bad(cmd, "no such parent page"))?;
Ok((si, pi))
}
pub(super) fn parent_page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
const ID: &str = "layout.parentPageSize";
let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?;
let size = |key| {
p.get(key)
.map(|v| {
v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)"))
})
.transpose()
};
let (w, h) = (size("width")?, size("height")?);
if w.is_none() && h.is_none() {
return Err(bad(ID, "give width or height"));
}
let anchor = match p.get("anchor") {
None => 0,
Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize,
};
s.edit(|d, _| {
super::pages::resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor);
d.sync_parent_margins();
Ok(json!({"parent": d.parents[si].id.0, "page": pi}))
})
}
#[cfg(test)]
mod parent_margin_tests {
use designcraft_doc::{Document, Margins};
@@ -117,6 +154,52 @@ mod parent_margin_tests {
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err());
}
/// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's
/// dialog offers; `after` and `before` together are refused without an undo step.
#[test]
fn pages_go_in_before_a_page_or_at_the_start() {
let mut s = Session::new();
// Facing: [1] [2 3].
s.execute("file.new", &json!({"pages": 3})).unwrap();
let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
let ids = |s: &Session| -> Vec<u64> {
let d = &s.doc().unwrap().doc;
(0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect()
};
let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0);
let old = ids(&s);
s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap();
let now = ids(&s);
assert_eq!(now.len(), 4);
assert_eq!(now[1..], old[..], "the old pages follow the new first page");
assert_eq!(parent(&s, 0), Some(b));
// Before page 4 (index 3, the old page 3): two pages without a parent.
s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap();
let then = ids(&s);
assert_eq!(then.len(), 6);
assert_eq!(then[..3], now[..3]);
assert_eq!(then[5], old[2]);
assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None));
// Refused without a change or an undo step: both placements, even one of them null, and a
// placement that isn't a page index.
let undo = s.doc().unwrap().history.undo.len();
for bad in [
json!({"after": 0, "before": 1}),
json!({"after": 0, "before": null}),
json!({"after": null, "before": 1}),
json!({"before": -1}),
json!({"before": "0"}),
json!({"before": null}),
json!({"after": 1.5}),
json!({"before": 6}),
json!({"before": 99}),
] {
assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}");
assert_eq!(ids(&s), then, "{bad}");
}
}
#[test]
fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() {
let mut s = Session::new();
+49 -6
View File
@@ -1,7 +1,7 @@
use super::layers::set_layer;
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
use super::pages::{document_setup, page_options, page_size};
use super::parents::{parent_options, parent_ref, parent_spread_index};
use super::parents::{parent_options, parent_page_size, parent_page_target, parent_ref, parent_spread_index};
use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number};
use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param};
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Pages…",
["Layout", "Pages"],
Some("Cmd+Shift+P"),
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}",
has_doc,
|s, p| {
const ID: &str = "layout.pages.insert";
@@ -27,12 +27,30 @@ pub fn specs() -> Vec<CommandSpec> {
};
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));
// A placement given (null included) must be a page index; only when neither is
// given do the pages go after the last.
if p.get("after").is_some() && p.get("before").is_some() {
return Err(bad("layout.pages.insert", "give `after` or `before`, not both"));
}
// `before` names a page of the document; an `after` past the last page appends
// (`insert_pages_bounds.rs` pins that).
let index = |k: &str| match p.get(k) {
None => Ok(None),
Some(v) => v
.as_u64()
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
.filter(|&i| k == "after" || i < n)
.map(|i| Some(i.min(n.saturating_sub(1))))
.ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))),
};
// The page the new ones follow; none: they go first.
let after = match (index("after")?, index("before")?) {
(_, Some(b)) => b.checked_sub(1),
(a, None) => Some(a.unwrap_or(n.saturating_sub(1))),
};
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)?;
let ids = d.insert_pages(after, count, parent)?;
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
})
}
@@ -484,6 +502,30 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
page_size
),
cmd!(
"layout.parentPageSize",
"Parent Page Size",
[],
None,
"{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent",
has_doc,
parent_page_size
),
cmd!(
noundo "layout.selectParentPage", "Select Parent Page", [], None,
"{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection",
has_doc, |s, p| {
let target = match p.get("parent") {
Some(Value::Null) => None,
_ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?),
};
let st = s.doc_mut()?;
st.selection = Default::default();
st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi));
st.bump_revision();
ok()
}
),
cmd!(
"layout.pageOptions",
"Page Options",
@@ -518,6 +560,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.selection = designcraft_doc::Selection::items(ids);
}
st.selection.page = page;
st.selection.parent_page = None;
st.bump_revision();
Ok(json!({"page": page.map(|p| p + 1)}))
}
+62 -23
View File
@@ -27,8 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
// Taken out for the call so the document needn't be cloned on every poll.
let mut checks = std::mem::take(&mut s.text_link_checks);
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
let mut images = std::mem::take(&mut s.image_link_checks);
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks, &mut images));
s.text_link_checks = checks;
s.image_link_checks = images;
Ok(Value::Array(links?))
}),
cmd!(
@@ -137,7 +139,7 @@ pub fn refused(link: &str, document: Option<&Path>) -> Option<crate::io::Refused
crate::io::document_path(link, document).err()
}
/// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An
/// Whether an asset's linked file is gone ([`status_cached`] "missing" without reading the file). An
/// asset can hold data while its file is missing: the document's preview of it. `document` is
/// the document's file, which relative links are resolved against.
pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool {
@@ -153,9 +155,20 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
}
}
/// Link status of an asset in the document at `document`. Only a regular file is compared, so a
/// link to a device (`/dev/zero`) or a FIFO is missing rather than read.
pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str {
/// Linked images checked this session: (asset, the identity of its bytes, the file its link
/// resolves to) → (the file's size and time when it was read, whether it matched the bytes). The
/// resolved file, not the link text, is the key: a relative link names another file once the
/// document is saved elsewhere. The bytes' identity (their `Arc`'s address, as the render cache
/// keys them) changes with every relink, update or undo, so a remembered answer can never
/// describe other bytes.
pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, std::path::PathBuf), (Fingerprint, bool)>;
/// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file
/// only when its size is the asset's and its time isn't the one remembered from the last read
/// (`checks`): the Links panel polls twice a second, and a document
/// of press-size images would otherwise be read whole on every poll. A file changed in the last
/// two seconds is never remembered (see [`settled`]).
pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str {
let Some(link) = &a.link else { return "embedded" };
// Nothing was read from the file (a package's link that wasn't followed): missing, as in
// Preflight.
@@ -168,20 +181,32 @@ pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static s
if !crate::io::is_file(&path) {
return "missing";
}
match crate::io::metadata(&path) {
Err(_) => "missing",
Ok(m) if m.len() as usize != a.data.len() => "modified",
// Same size: compare the bytes (cheap for the sizes involved, and exact).
Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) {
Ok(b) if b == *a.data => "ok",
Ok(_) => "modified",
Err(_) => "missing",
},
let now = match crate::io::metadata(&path) {
Err(_) => return "missing",
Ok(m) => super::datamerge::fingerprint_from(&m),
};
if now.size != a.data.len() as u64 {
return "modified";
}
let key = (a.id, Arc::as_ptr(&a.data) as usize, path.clone());
if let Some((at, same)) = checks.get(&key)
&& *at == now
{
return if *same { "ok" } else { "modified" };
}
let same = match crate::io::read_capped(&path, a.data.len() as u64) {
Ok(b) => b == *a.data,
Err(_) => return "missing",
};
// The time from before the read: a change during it is seen next time.
if settled(&now) {
checks.insert(key, (now, same));
}
if same { "ok" } else { "modified" }
}
#[cfg(target_arch = "wasm32")]
{
let _ = (link, document);
let _ = (link, document, checks);
"ok"
}
}
@@ -317,7 +342,9 @@ fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec<Shown<'a>> {
out
}
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
/// `links.list`: every used placed graphic, then every linked text file, each with its status
/// (`images` and `checks` remember what was read this session, so a poll reads nothing unchanged).
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks, images: &mut ImageChecks) -> Vec<Value> {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
@@ -350,7 +377,7 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextC
.collect();
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string());
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status_cached(a, document, images), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
})
.chain(texts)
.collect()
@@ -450,6 +477,7 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result
Ok(Arc::new(a))
}
/// `links.relink`: a placed graphic or linked text file takes its bytes from `path` from now on.
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string();
if let Some(sid) = story_param(p) {
@@ -464,13 +492,17 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
// Build the replacement before the edit: a file that cannot be read leaves the document as it was.
let fresh = relinked_asset(a, Path::new(&path), &path)?;
s.edit(|d, _| {
let r = s.edit(|d, _| {
let name = fresh.name.clone();
d.assets.insert(aid, fresh);
Ok(json!({"name": name}))
})
});
s.image_link_checks.clear();
r
}
/// `links.relinkFolder`: relink graphics to same-named files in a folder and/or with another
/// extension (the missing ones by default).
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
let dir = str_param(p, "dir").map(std::path::PathBuf::from);
if let Some(dir) = &dir {
@@ -486,12 +518,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
}
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
let only: Option<Vec<u64>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
let mut images = std::mem::take(&mut s.image_link_checks);
let st = s.doc()?;
let (d, document) = (&st.doc, st.path.as_deref().map(Path::new));
let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new();
let mut not_found = Vec::new();
for a in d.assets.values() {
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") {
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status_cached(a, document, &mut images) != "missing") {
continue;
}
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
@@ -538,10 +571,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
images.clear();
}
s.image_link_checks = images;
Ok(json!({"relinked": n, "notFound": not_found}))
}
/// `links.update`: re-read the modified links (one, or every one) from where they resolve.
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let (d, document) = {
let st = s.doc()?;
@@ -549,6 +585,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
};
let document = document.as_deref().map(Path::new);
let checks = &mut s.text_link_checks;
let images = &mut s.image_link_checks;
let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p));
let all = only_asset.is_none() && only_story.is_none();
// A link craft-docpath refuses is missing, so it is never read or refreshed.
@@ -563,7 +600,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let mut fresh = Vec::new();
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") {
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status_cached(a, document, images) == "modified") {
// The bytes come from where the stored link resolves; the link itself stays as the
// document stored it, so refreshing never rewrites a relative link to an absolute one.
if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) {
@@ -579,7 +616,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
if n == 0 {
return Ok(json!({"updated": 0}));
}
s.edit(|d, _| {
let r = s.edit(|d, _| {
for (aid, a) in fresh {
d.assets.insert(aid, a);
}
@@ -588,7 +625,9 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
warnings.extend(reimport(d, *sid, path, bytes, *before)?);
}
Ok(json!({"updated": n, "warnings": warnings}))
})
});
s.image_link_checks.clear();
r
}
#[cfg(test)]
+73 -1
View File
@@ -4,6 +4,78 @@ fn png(w: u32, h: u32) -> Vec<u8> {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
/// The Links panel polls `links.list` twice a second: a linked image whose file keeps its size
/// and time is not read again (press-size images would otherwise be read whole on every poll).
/// A new time is read once; changed bytes show as modified; an update is remembered.
#[cfg(all(unix, not(target_arch = "wasm32")))]
#[test]
fn unchanged_linked_images_are_not_read_again() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("dc-image-link-poll-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("photo.png");
// Two valid images of the same byte length (a uniform fill compresses to the same size
// whatever its value), so a change shows only in the bytes.
let a = png(40, 40);
let b = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![100u8; 40 * 40 * 4] }.to_png();
assert_eq!(a.len(), b.len(), "the test needs two files of one size");
assert_ne!(a, b);
std::fs::write(&path, &a).unwrap();
// Unreadable: a status that reads the file would say missing.
let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap();
// A time `secs` ago (a file changed in the last two seconds is always read).
let touch = |secs: u64| {
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap();
};
touch(60);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap();
let status = |s: &mut Session| {
let l = s.execute("links.list", &json!({})).unwrap();
l.as_array().unwrap().iter().find(|r| r.get("asset").is_some()).unwrap()["status"].as_str().unwrap().to_string()
};
// First poll reads and remembers; the second doesn't read.
assert_eq!(status(&mut s), "ok");
readable(false);
if std::fs::read(&path).is_ok() {
// Running as root: permissions don't stop reads, so this can't be observed.
let _ = std::fs::remove_dir_all(&dir);
return;
}
assert_eq!(status(&mut s), "ok", "same size and time: not read");
// Saved again unchanged (a new time): read once, then not again.
readable(true);
touch(30);
assert_eq!(status(&mut s), "ok");
readable(false);
assert_eq!(status(&mut s), "ok", "the second poll doesn't read it");
// Changed, same size, new time: modified — and remembered, not re-read.
readable(true);
std::fs::write(&path, &b).unwrap();
touch(20);
assert_eq!(status(&mut s), "modified");
readable(false);
assert_eq!(status(&mut s), "modified", "a modified answer is remembered too");
// Updated: the document's bytes are the file's, so the next poll reads once and says ok.
readable(true);
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
assert_eq!(status(&mut s), "ok");
readable(false);
assert_eq!(status(&mut s), "ok", "remembered after the update");
// A different size needs no read at all.
readable(true);
std::fs::write(&path, png(41, 41)).unwrap();
touch(10);
readable(false);
assert_eq!(status(&mut s), "modified", "size differs: decided from metadata");
readable(true);
let _ = std::fs::remove_dir_all(&dir);
}
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
#[cfg(not(target_arch = "wasm32"))]
#[test]
@@ -154,7 +226,7 @@ fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() {
std::fs::remove_file(&replacement).unwrap();
let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
// The asset is embedded, so no link is followed and the document is irrelevant.
assert_eq!(status(&embedded.assets[&aid], None), "embedded");
assert_eq!(status_cached(&embedded.assets[&aid], None, &mut Default::default()), "embedded");
assert_eq!(embedded.assets[&aid].data.as_slice(), changed);
assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some());
let _ = std::fs::remove_dir_all(dir);
+2 -2
View File
@@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) {
let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::<Vec<_>>();
let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal));
// The page's objects: centres within its old horizontal extent (outer pages take the pasteboard).
let last = sp.pages.len() - 1;
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 });
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 });
let strokes = false;
let Some(sp) = d.spread_mut(sr) else { return };
for it in &mut sp.items {
+25 -2
View File
@@ -35,6 +35,11 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des
it
}
/// The `size` parameter in points: 12 by default (also for non-finite values), clamped to 2..=400.
fn size_param(p: &Value) -> f64 {
p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(12.0).clamp(2.0, 400.0)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -48,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "math.svg", "Math Expression SVG", [], None, "{latex, size?} → {svg, width, height} — typeset without placing", has_doc, |_s, p| {
let latex = str_param(p, "latex").ok_or_else(|| bad("math.svg", "`latex` required"))?;
let (svg, (w, h)) = crate::math::to_svg(latex, p.get("size").and_then(Value::as_f64).unwrap_or(12.0));
let (svg, (w, h)) = crate::math::to_svg(latex, size_param(p));
Ok(json!({"svg": String::from_utf8_lossy(&svg), "width": w, "height": h}))
}),
]
@@ -56,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let latex = str_param(p, "latex").ok_or_else(|| bad("math.insert", "`latex` required"))?.to_string();
let size = p.get("size").and_then(Value::as_f64).unwrap_or(12.0).clamp(2.0, 400.0);
let size = size_param(p);
let lid = s.doc()?.active_layer;
// Edit an existing expression in place.
if let Some(id) = super::id_param(p, "id") {
@@ -102,6 +107,24 @@ mod tests {
use crate::Session;
#[test]
fn math_svg_clamps_size_like_insert() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let dims = |s: &mut Session, size: f64| {
let r = s.execute("math.svg", &json!({"latex": "x", "size": size})).unwrap();
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
};
let (w2, h2) = dims(&mut s, 2.0);
let (w400, h400) = dims(&mut s, 400.0);
for size in [0.0, -5.0, 1e-300] {
assert_eq!(dims(&mut s, size), (w2, h2), "size {size}");
}
for size in [1e9, 1e300] {
assert_eq!(dims(&mut s, size), (w400, h400), "size {size}");
}
}
#[test]
fn math_places_inline_and_as_frames() {
let mut s = Session::new();
+290 -54
View File
@@ -382,9 +382,10 @@ pub fn specs() -> Vec<CommandSpec> {
"Drop Shadow",
["Object", "Effects"],
Some("Cmd+Alt+M"),
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}",
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}",
has_selection,
|s, p| {
let mode = blend_param(p, "object.dropShadow")?;
let p2 = p.clone();
set_effects(s, p, "object.dropShadow", move |fx| {
let ds = &mut fx.drop_shadow;
@@ -398,6 +399,9 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(c) = str_param(&p2, "color") {
ds.color = c.into();
}
if let Some(m) = mode {
ds.mode = m;
}
})
}
),
@@ -406,9 +410,10 @@ pub fn specs() -> Vec<CommandSpec> {
"Inner Shadow",
["Object", "Effects"],
None,
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}",
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}",
has_selection,
|s, p| {
let mode = blend_param(p, "object.innerShadow")?;
let p2 = p.clone();
set_effects(s, p, "object.innerShadow", move |fx| {
let e = &mut fx.inner_shadow;
@@ -422,6 +427,9 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
if let Some(m) = mode {
e.mode = m;
}
})
}
),
@@ -430,9 +438,10 @@ pub fn specs() -> Vec<CommandSpec> {
"Outer Glow",
["Object", "Effects"],
None,
"{on?: bool, opacity?, size?, spread? (%), color?, ids?}",
"{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}",
has_selection,
|s, p| {
let mode = blend_param(p, "object.outerGlow")?;
let p2 = p.clone();
set_effects(s, p, "object.outerGlow", move |fx| {
let e = &mut fx.outer_glow;
@@ -443,6 +452,9 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
if let Some(m) = mode {
e.mode = m;
}
})
}
),
@@ -598,7 +610,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
@@ -607,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Corner Options…",
["Object"],
None,
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}",
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}",
has_selection,
corner_options
),
@@ -616,7 +628,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Wrap",
["Window"],
Some("Cmd+Alt+W"),
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
has_selection,
text_wrap
),
@@ -638,6 +650,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
fitting_options
),
cmd!(
"object.clearFittingOptions",
"Clear Frame Fitting Options",
["Object", "Fitting"],
None,
"{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is",
has_selection,
clear_fitting_options
),
cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| {
let name = str_param(p, "name").unwrap_or("").to_string();
set_flag(s, p, move |i| i.name = name.clone(), false)
@@ -680,7 +701,15 @@ pub fn specs() -> Vec<CommandSpec> {
path_reverse
),
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
cmd!(
"path.moveAnchors",
"Move Anchors",
[],
None,
"{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space",
has_doc,
path_move_anchors
),
cmd!(
"object.align",
"Align",
@@ -2101,13 +2130,18 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let mode: Option<WrapMode> = match p.get("mode") {
// Null keeps each object's own mode (an edit of the offsets or Invert alone).
Some(Value::Null) => None,
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?),
None if side.is_some() || contour.is_some() => None,
None => Some(WrapMode::BoundingBox),
};
let off = match p.get("offset") {
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
Some(v) => serde_json::from_value(v.clone()).ok(),
// Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so
// an edit of one side keeps the others of every object.
// An array with no number in it changes no offset (and so keeps a custom boundary).
let off: Option<[Option<f64>; 4]> = match p.get("offset") {
Some(Value::Number(n)) => Some([n.as_f64(); 4]),
Some(v) => serde_json::from_value::<[Option<f64>; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)),
None => None,
};
let invert = p.get("invert").and_then(Value::as_bool);
@@ -2127,7 +2161,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
w.mode = m;
}
if let Some(o) = off {
w.offsets = o;
for (w, v) in w.offsets.iter_mut().zip(o) {
if let Some(v) = v {
*w = v;
}
}
}
if let Some(v) = invert {
w.invert = v;
@@ -2152,19 +2190,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
use designcraft_geom::corners::{Corner, CornerOptions, CornerShape};
let opts = match p.get("corners") {
Some(v) => {
let corners: [Corner; 4] =
serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?;
if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) {
let corners: [Option<Corner>; 4] = serde_json::from_value(v.clone())
.map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?;
if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) {
return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative"));
}
CornerOptions { corners }
corners
}
None => {
let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded);
CornerOptions::uniform(shape, f64_or(p, "size", 12.0))
CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some)
}
};
set_flag(s, p, move |i| i.corners = opts, false)
// A null corner keeps each object's own.
set_flag(
s,
p,
move |i| {
for (c, new) in i.corners.corners.iter_mut().zip(opts) {
if let Some(new) = new {
*c = new;
}
}
},
false,
)
}
/// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance.
@@ -2311,6 +2361,15 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(patch) = &patch {
let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?;
// Default effects are left out of the serialized form; a patch without `mode` would
// then read the legacy Normal instead of the effect's current (Multiply, Screen) mode.
for (key, now) in
[("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))]
{
if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) {
o.entry(key).or_insert(now);
}
}
merge_json(&mut cur, patch);
let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?;
sanitize_effects(&mut fx)?;
@@ -2505,6 +2564,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?;
let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?;
let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?;
let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?;
let reference = match p.get("autoSizeReference") {
None | Some(Value::Null) => None,
Some(v) => Some(
@@ -2591,6 +2651,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = reference {
o.auto_size_ref = v;
}
o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit);
if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) {
o.auto_size_use_min_height = v;
}
@@ -2938,7 +2999,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
} else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None {
g.auto_fit = mode;
}
if let Some(xf) = g.fitted(r, mode) {
// Turning Auto-Fit off alone leaves the picture where the user put it.
let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none();
if !turn_off_only && let Some(xf) = g.fitted(r, mode) {
g.xf = xf;
}
n += 1;
@@ -2947,6 +3010,35 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content
/// stays where it is, as in InDesign.
fn clear_fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
// Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose
// options are already clear are left out; with nothing to change, no edit (and no empty undo
// step).
let doc = &s.doc()?.doc;
let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4;
let ids: Vec<ItemId> = targets(s, p)?
.into_iter()
.filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g))))
.collect();
if ids.is_empty() {
return Ok(json!({"changed": 0}));
}
s.edit(|d, _| {
let mut n = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let Content::Graphic(g) = &mut it.content else { continue };
g.auto_fit = designcraft_doc::Fitting::None;
g.crop = [0.0; 4];
g.fit_align = 4;
n += 1;
}
Ok(json!({"changed": n}))
})
}
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let ids: Vec<_> =
@@ -3174,6 +3266,20 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved).
/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode.
fn blend_param(p: &Value, cmd: &str) -> Result<Option<BlendMode>> {
match p.get("mode") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_str()
.and_then(BlendMode::parse)
.or_else(|| serde_json::from_value(v.clone()).ok())
.map(Some)
.ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))),
}
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
if ids.len() < 3 && p.get("spacing").is_none() {
@@ -3266,29 +3372,36 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
let subpath = p.get("subpath").and_then(Value::as_u64);
// An omitted subpath is the last one; one given must name a subpath (never falls back).
let subpath = match p.get("subpath") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?),
};
let at_start = match p.get("at") {
None => false,
Some(v) if v.as_str() == Some("end") => false,
Some(v) if v.as_str() == Some("start") => true,
Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")),
};
s.edit(|d, _| {
s.edit(|d, sel| {
path_editable(d, id, "path.appendAnchor")?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = match subpath {
Some(i) => {
usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?
}
None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
let si = match subpath {
Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?,
None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
};
let sp = &mut it.path.subpaths[si];
if sp.closed {
return Err(bad("path.appendAnchor", "the subpath is closed"));
}
if at_start {
sp.anchors.insert(0, a.transform(inv));
// The selected points of this sub-path move up one place with the others.
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) {
a.2 += 1;
}
} else {
sp.anchors.push(a.transform(inv));
}
@@ -3367,7 +3480,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
{
path_editable(doc, *id, "path.reverse")?;
}
s.edit(|d, _| {
s.edit(|d, sel| {
let mut reversed = 0;
for id in &editable {
let Some(it) = d.item_mut(*id) else { continue };
@@ -3378,6 +3491,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
if only.is_some_and(|o| o != i) {
continue;
}
// Selected points keep pointing at the same points, now counted from the other end.
let n = sp.anchors.len();
for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) {
a.2 = n - 1 - a.2;
}
sp.anchors.reverse();
for a in &mut sp.anchors {
std::mem::swap(&mut a.h_in, &mut a.h_out);
@@ -3411,38 +3529,75 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
}
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
// The anchors to move, by path: `points` (several paths) or `id` with `anchors`.
// Each anchor with its own spread-space movement, when the points give one.
let mut targets: Vec<(ItemId, Vec<(usize, usize, Option<Vec2>)>)> = Vec::new();
// A `points` that isn't a list is an error, never the `id` form (which could move another path).
let points = match p.get("points") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => Some(a),
Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index, dx?, dy?]")),
};
if let Some(points) = points {
for v in points {
let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else {
return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]"));
};
let own = match (v.get(3), v.get(4)) {
(None, None) => None,
(Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)),
_ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")),
},
_ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")),
};
match targets.iter_mut().find(|(i, _)| i.0 == id) {
Some((_, which)) => which.push((si, ai, own)),
None => targets.push((ItemId(id), vec![(si, ai, own)])),
}
}
} else {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect()));
}
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let handle = str_param(p, "handle").map(str::to_string);
s.edit(|d, _| {
path_editable(d, id, "path.moveAnchors")?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
// Spread-space delta → inner-space delta (linear part only).
let inv = m.inverse();
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
for (si, ai) in &which {
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
match handle.as_deref() {
Some("in") => {
a.h_in += dv;
if a.kind == designcraft_geom::AnchorKind::Smooth {
a.h_out = a.p - (a.h_in - a.p);
}
}
Some("out") => {
a.h_out += dv;
if a.kind == designcraft_geom::AnchorKind::Smooth {
a.h_in = a.p - (a.h_out - a.p);
}
}
_ => a.translate(dv),
}
for (id, _) in &targets {
path_editable(d, *id, "path.moveAnchors")?;
}
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
it.shape = Shape::Path;
for (id, which) in &targets {
let id = *id;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
// Spread-space delta → inner-space delta (linear part only).
let inv = m.inverse();
let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO);
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
for (si, ai, own) in which {
let dv = inner(own.unwrap_or(d0));
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
match handle.as_deref() {
Some("in") => {
a.h_in += dv;
if a.kind == designcraft_geom::AnchorKind::Smooth {
a.h_out = a.p - (a.h_in - a.p);
}
}
Some("out") => {
a.h_out += dv;
if a.kind == designcraft_geom::AnchorKind::Smooth {
a.h_in = a.p - (a.h_out - a.p);
}
}
_ => a.translate(dv),
}
}
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
it.shape = Shape::Path;
}
}
ok()
})
@@ -4143,6 +4298,31 @@ mod label_tests {
mod light_tests {
use super::*;
/// The shadow and glow commands take a blend mode by name.
#[test]
fn shadow_and_glow_commands_take_a_blend_mode() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap();
s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap();
s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap();
let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone();
assert_eq!(fx.drop_shadow.mode, BlendMode::Screen);
assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay);
assert_eq!(fx.outer_glow.mode, BlendMode::Normal);
let before = s.doc().unwrap().doc.clone();
for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] {
for mode in [json!("multply"), json!(3)] {
let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err();
assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}");
}
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode");
}
#[test]
fn global_light_moves_shadows_that_use_it() {
let mut s = Session::new();
@@ -4352,6 +4532,60 @@ mod fitting_tests {
let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap();
assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally);
}
/// Turning Auto-Fit off by itself keeps a moved picture where it is.
#[test]
fn auto_fit_off_alone_keeps_the_picture_position() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap();
let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf;
s.edit(|d, _| {
if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) {
g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf;
}
Ok(Value::Null)
})
.unwrap();
let moved = xf(&s);
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap();
assert_eq!(xf(&s), moved);
}
/// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their
/// defaults and leaves the picture where it is.
#[test]
fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap();
let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone();
let before = graphic(&s);
assert_ne!(before.auto_fit, designcraft_doc::Fitting::None);
s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
let g = graphic(&s);
assert_eq!(g.auto_fit, designcraft_doc::Fitting::None);
assert_eq!(g.crop, [0.0; 4]);
assert_eq!(g.fit_align, 4, "the centre reference point");
assert_eq!(g.xf, before.xf, "the picture stays where it is");
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step");
s.execute("object.lock", &json!({"ids": [f]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
assert_eq!(r["changed"], 0);
assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step");
}
}
#[cfg(test)]
@@ -5538,12 +5772,14 @@ mod effects_command_tests {
let it = st.doc.item(ItemId(id)).unwrap();
let e = &it.effects;
assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0);
assert_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default");
assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay");
assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply));
// Out of range values are kept in range; garbage changes nothing.
s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap();
let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone();
assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0));
assert_eq!(og.mode, designcraft_color::BlendMode::Screen);
let before = s.doc().unwrap().doc.clone();
for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})]
{
+18 -7
View File
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Package…",
["File"],
None,
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
"{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
has_doc,
package
),
@@ -390,6 +390,17 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
let st = s.doc()?;
// The PDF: with the default settings, or with the options of a PDF preset, made as
// file.exportPdf makes it. Read first, so that bad options write nothing.
let pdf = match p.get("pdf") {
None | Some(Value::Null) | Some(Value::Bool(false)) => None,
Some(Value::Bool(true)) => Some(json!({})),
Some(o @ Value::Object(_)) => {
super::export::check_pdf_options(o, st.doc.page_count())?;
Some(o.clone())
}
Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))),
};
let root = OutputRoot::new(dir.clone())?;
let display_title = st.doc.title.clone();
let title = safe_component(&display_title, "Untitled");
@@ -441,18 +452,18 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
}
}
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
if let Some(params) = pdf {
let path = package_output_path(&dir, &title, "pdf")?;
let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default())
.map_err(|e| EngineError::Other(e.to_string()))?;
root.write(&path, &bytes)?;
root.write(&path, &super::export::pdf_report(&packed, &s.cache, &params)?.bytes)?;
files.push(path);
}
// The report: fonts (missing first), links, preflight, the user's instructions.
let mut r = format!("Package report: {display_title}\n\n");
// The printing instructions first, as the form gave them; then the report: fonts (missing
// first), links, preflight.
let mut r = String::new();
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
r += &format!("Instructions\n{t}\n\n");
}
r += &format!("Package report: {display_title}\n\n");
r += "Fonts\n";
r += &font_lines.concat();
if !fallback_lines.is_empty() {
+53
View File
@@ -25,6 +25,58 @@ fn relocated_packages_rebind_embedded_image_links_on_open() {
std::fs::remove_dir_all(dir).unwrap();
}
/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the
/// default PDF, and bad options are refused.
#[test]
fn package_pdf_takes_export_options() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id()));
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap();
let x4 = dir.join("x4");
s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap();
let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what);
assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX"));
let plain = dir.join("plain");
s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap();
assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX"));
// Refused before anything is written: bad options, a bad flattener or view option, and a
// `pdf` that is neither a switch nor options.
for bad in [
json!({"standard": "nope"}),
json!({"flatten": "extreme"}),
json!({"pageLayout": "spiral"}),
json!({"view": "zoomed"}),
json!("print"),
json!(3),
] {
let out = dir.join("bad");
assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}");
assert!(!out.exists(), "{bad}: bad options write nothing");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged
/// PDF the flattening `file.exportPdf` does.
#[test]
fn the_packaged_pdf_is_flattened_as_export_flattens_it() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id()));
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap();
let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap());
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap();
let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap();
let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count();
assert!(images(&exported) > 0, "the export rasterises the transparent object");
assert_eq!(images(&packaged), images(&exported), "so does the package");
std::fs::remove_dir_all(dir).unwrap();
}
/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the
/// report says the copies differ instead of leaving it to be discovered.
#[test]
@@ -169,6 +221,7 @@ fn package_writes_document_links_and_report() {
assert!(out.join("Links").join("logo.svg").exists());
let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap();
assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}");
assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}");
assert!(report.contains("Preflight:"), "{report}");
// The packaged document links to its Links folder.
let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap();
@@ -0,0 +1,299 @@
use std::sync::Arc;
use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId};
use designcraft_geom::Rect;
use serde_json::json;
use crate::Session;
fn parent_id(s: &Session, index: usize) -> u64 {
s.doc().unwrap().doc.parents[index].id.0
}
fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page {
s.doc().unwrap().doc.parents[index].pages[page].clone()
}
fn parent_frame(s: &mut Session, x: f64) -> u64 {
s.execute(
"frame.create",
&json!({
"spread": {"kind": "parent", "index": 0},
"rect": [x, 80.0, x + 100.0, 180.0]
}),
)
.unwrap()["id"]
.as_u64()
.unwrap()
}
fn bounds(s: &Session, id: u64) -> Rect {
s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds()
}
fn guide_positions(s: &Session, page: usize) -> Vec<f64> {
s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect()
}
fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) {
s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap();
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft");
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left });
let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap();
let other = 1 - target;
let target_page = parent_page(s, 0, target);
let other_page = parent_page(s, 0, other);
let target_item = parent_frame(s, target_page.x + 80.0);
let other_item = parent_frame(s, other_page.x + 80.0);
s.execute(
"guide.add",
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}),
)
.unwrap();
s.execute(
"guide.add",
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}),
)
.unwrap();
s.execute(
"guide.add",
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}),
)
.unwrap();
s.execute(
"guide.add",
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}),
)
.unwrap();
s.edit(|d, _| {
let sp = Arc::make_mut(&mut d.parents[0]);
let positions = |page: &designcraft_doc::Page| {
let w = page.margin_rect().width();
vec![0.0, 100.0, 120.0, w]
};
for page in &mut sp.pages {
page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) };
}
sp.pages[target].liquid = rule;
Ok(())
})
.unwrap();
(parent_id(s, 0), target_item, other_item, target, other)
}
#[test]
fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() {
for binding in ["leftToRight", "rightToLeft"] {
for rule in [
LiquidRule::Off,
LiquidRule::Scale,
LiquidRule::ReCenter,
LiquidRule::ObjectBased,
LiquidRule::GuideBased,
LiquidRule::ControlledByParent,
] {
let mut s = Session::new();
let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule);
let before_target = parent_page(&s, 0, target);
let before_other = parent_page(&s, 0, other);
let before_target_item = bounds(&s, target_item);
let before_other_item = bounds(&s, other_item);
let before_target_guides = guide_positions(&s, target);
let before_other_guides = guide_positions(&s, other);
let before_target_columns = before_target.columns.positions.clone();
let before_other_columns = before_other.columns.positions.clone();
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap();
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target)));
assert!(s.doc().unwrap().selection.items.is_empty());
s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap();
let after_target = parent_page(&s, 0, target);
let after_other = parent_page(&s, 0, other);
assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}");
assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}");
assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}");
assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}");
assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}");
assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}");
assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}");
assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}");
assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0);
assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0);
s.doc().unwrap().doc.check().unwrap();
}
}
}
#[test]
fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
let a = parent_id(&s, 0);
let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap();
s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap();
s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap();
let before = s.doc().unwrap().doc.as_ref().clone();
let undo_before = s.doc().unwrap().history.undo.len();
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.parents[0].pages[0].width, 0.5);
assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width);
assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width);
assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a)));
assert_eq!(d.settings.page_width, before.settings.page_width);
d.check().unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn parent_resize_survives_native_and_idml_round_trips() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
let a = parent_id(&s, 0);
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap();
let d = s.doc().unwrap().doc.as_ref().clone();
d.check().unwrap();
let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap();
assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75));
native.check().unwrap();
let idml = designcraft_idml::export_idml(&d);
let reopened = designcraft_idml::import_idml(&idml).unwrap();
assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75));
reopened.check().unwrap();
}
#[test]
fn parent_resize_rejects_invalid_inputs_atomically() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let parent = parent_id(&s, 0);
let before = s.doc().unwrap().doc.as_ref().clone();
let undo_before = s.doc().unwrap().history.undo.len();
let invalid = [
json!({"parent": parent, "page": 0, "width": null}),
json!({"parent": parent, "page": 0, "width": "400"}),
json!({"parent": parent, "page": 0, "width": "NaN"}),
json!({"parent": parent, "page": 0, "width": "Infinity"}),
json!({"parent": parent, "page": 0, "width": 0}),
json!({"parent": parent, "page": 0, "width": -1}),
json!({"parent": parent, "page": 0, "width": 15552.1}),
json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}),
json!({"parent": parent, "page": 1, "width": 400}),
json!({"parent": 999_999, "page": 0, "width": 400}),
];
for params in invalid {
assert!(s.execute("layout.parentPageSize", &params).is_err(), "accepted {params}");
assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}");
}
assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err());
assert_eq!(s.doc().unwrap().doc.as_ref(), &before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn parent_and_document_page_selection_validate_and_clear_each_other() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let parent = parent_id(&s, 0);
let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap();
assert!(!s.doc().unwrap().selection.items.is_empty());
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
assert!(s.doc().unwrap().selection.items.is_empty());
assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err());
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap();
assert_eq!(s.doc().unwrap().selection.page, Some(0));
assert_eq!(s.doc().unwrap().selection.parent_page, None);
s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap();
assert_eq!(s.doc().unwrap().selection, Default::default());
}
#[test]
fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() {
for binding in ["leftToRight", "rightToLeft"] {
for pi in 0..2 {
let mut s = Session::new();
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
let id = parent_id(&s, 0);
let before = s.doc().unwrap().doc.clone();
let spine = before.parents[0].spine_x();
let other = 1 - pi;
let ox = before.parents[0].pages[other].x + 80.0;
let object = parent_frame(&mut s, ox);
let old_object = bounds(&s, object);
for params in
[json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})]
{
s.execute("layout.parentPageSize", &params).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}");
assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}");
}
}
}
}
#[test]
fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
let id = parent_id(&s, 0);
s.edit(|d, _| {
let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1];
pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) };
d.sync_parent_margins();
Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true;
Ok(())
})
.unwrap();
let before = s.doc().unwrap().doc.clone();
s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns);
assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins);
assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0));
assert_eq!(d.page(2).unwrap(), before.page(2).unwrap());
d.check().unwrap();
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap();
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap();
assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0));
}
#[test]
fn parent_resize_facing_sizes_survive_idml_in_both_bindings() {
for binding in ["leftToRight", "rightToLeft"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
let id = parent_id(&s, 0);
for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] {
s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap();
}
let d = &s.doc().unwrap().doc;
let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap();
assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding);
for page in &d.parents[0].pages {
let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap();
assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side);
}
for abs in 0..d.page_count() {
let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap());
assert_eq!((new.width, new.height), (old.width, old.height));
}
reopened.check().unwrap();
}
}
+152 -6
View File
@@ -38,6 +38,25 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usi
Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0))
}
/// Every page of the spread the frame reaches, not just the one its centre is on.
///
/// A sheet draws the items of its spread without testing which page they sit on, so a frame that
/// straddles two pages is drawn, in part, on both. Choosing by the centre alone dropped the
/// warning for a frame whose centre is on a page the export leaves out while the part on an
/// exported page went into the file (noted in review). Empty when the frame is on no document
/// spread, which is the same answer `page_of` gives.
fn pages_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Vec<usize> {
let SpreadRef::Doc(si) = sr else { return vec![] };
let Some(sp) = d.spreads.get(si) else { return vec![] };
let bounds = parent.transform_rect_bbox(it.bounds());
let first = d.first_page_of_spread(si);
let touched: Vec<usize> =
sp.pages.iter().enumerate().filter(|(_, pg)| bounds.x1 > pg.x && bounds.x0 < pg.x + pg.width).map(|(i, _)| first + i).collect();
// A frame wholly on the pasteboard touches no page's span; it still belongs to the page
// `page_at_x` nominates, so the two functions agree about where it is.
if touched.is_empty() { page_of(d, sr, it, parent).into_iter().collect() } else { touched }
}
/// Characters a missing-glyph report names before "and N more".
const MISSING_GLYPHS_LISTED: usize = 20;
@@ -74,11 +93,19 @@ fn missing_glyphs(
}
}
/// Locate an embedded frame at the outer page frame that actually renders it.
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option<usize>)> {
/// Locate an embedded frame at the outer page frame that actually renders it: the frame, the page
/// its centre is on, and every page it reaches (see [`pages_of`]).
type Located = (designcraft_doc::ItemId, Option<usize>, Vec<usize>);
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<Located> {
let d = &s.active()?.doc;
let at = |id: designcraft_doc::ItemId, loc: &designcraft_doc::ItemLoc| {
let it = d.item(id);
let xf = d.parent_xf(loc);
(id, it.and_then(|it| page_of(d, loc.spread, it, xf)), it.map(|it| pages_of(d, loc.spread, it, xf)).unwrap_or_default())
};
if let Some(loc) = d.find(target) {
return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
return Some(at(target, &loc));
}
fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool {
let mut frames = vec![root];
@@ -107,12 +134,131 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des
let frame = cs.frames.iter().find(|f| contains(f, current))?;
current = frame.frame;
if let Some(loc) = d.find(current) {
return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
return Some(at(current, &loc));
}
}
None
}
/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset
/// part of a story, and a story with no frame at all. The export used to write the file and
/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465).
///
/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the
/// 0-based pages being exported; a story whose overset is on a page that is not being exported is
/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a
/// text export carries the whole story, so neither calls this.
/// Pages a single warning names before "and N more".
const MAX_WARNED_PAGES: usize = 10;
/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more".
fn page_list(pages: &[usize]) -> String {
let shown: Vec<String> = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect();
let rest = pages.len() - shown.len();
let list = shown.join(", ");
match (pages.len(), rest) {
(1, _) => format!("page {list}"),
(_, 0) => format!("pages {list}"),
_ => format!("pages {list} and {rest} more"),
}
}
/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page.
fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> {
let loc = d.find(target)?;
let SpreadRef::Parent(pi) = loc.spread else { return None };
let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds());
Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0)))
}
/// Document pages that draw parent page `pp` of parent spread `pi`, in document order.
fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec<usize> {
(0..d.page_count())
.filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp))))
.collect()
}
/// Characters of `story` that `cs` had no room for.
fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize {
story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count()
}
pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec<String> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
let mut out = Vec::new();
for story in d.stories.values() {
let last = story.frames.last().copied();
// A story on a parent page is composed once per page that shows it: its page number
// markers and variables are that page's, so the same frame can fit on one page and
// overset on another, and a parent page no exported page uses puts nothing in the file
// to be missing. Composing it once with no page reported neither (noted in review).
if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) {
let mut by_count: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
for page in pages_showing(d, parent) {
if pages.is_some_and(|sel| !sel.contains(&page)) {
continue;
}
let cs = s.cache.get(d, story.id, Some(page));
let n = overset_chars(story, &cs);
if cs.is_overset() && n > 0 {
by_count.entry(n).or_default().push(page);
}
}
let item = last.map_or(0, |f| f.0);
out.extend(
by_count.into_iter().map(|(n, ps)| {
format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps))
}),
);
continue;
}
let cs = s.cache.get(d, story.id, None);
if !cs.is_overset() {
continue;
}
let n = overset_chars(story, &cs);
if n == 0 {
continue;
}
if story.frames.is_empty() {
out.push(format!("a story with no text frame: {n} character(s) of it are not in the export"));
continue;
}
let location = last.and_then(|f| overset_location(s, f));
let centre = location.as_ref().and_then(|(_, page, _)| *page);
let touched: &[usize] = location.as_ref().map(|(_, _, ps)| ps.as_slice()).unwrap_or_default();
// A sheet draws its spread's items without asking which page they are on, so a frame
// straddling two pages is drawn in part on both. Testing the centre page alone dropped
// the warning for a frame centred on a page the export leaves out while the part on an
// exported page went into the file (noted in review).
//
// The page named is one the export has: the centre page when that is exported, so an
// ordinary frame reads as before, otherwise the first exported page the frame reaches.
let page = match pages {
None => centre,
Some(sel) => {
let exported: Vec<usize> = touched.iter().copied().filter(|p| sel.contains(p)).collect();
match centre.filter(|p| sel.contains(p)).or_else(|| exported.first().copied()) {
Some(p) => Some(p),
// Nothing of this frame is on an exported page.
None if centre.is_some() || !touched.is_empty() => continue,
None => None,
}
}
};
let item = location.as_ref().map(|(item, _, _)| *item).or(last);
out.push(match (page, item) {
(Some(page), Some(item)) => {
format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0)
}
(None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0),
_ => format!("{n} character(s) of a story do not fit their frame and are not in the export"),
});
}
out
}
pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
@@ -138,8 +284,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
} else {
format!("Overset text: {n} characters")
},
item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)),
page: location.and_then(|(_, page)| page),
item: location.as_ref().map(|(item, _, _)| item.0).or(last.map(|i| i.0)),
page: location.as_ref().and_then(|(_, page, _)| *page),
});
}
}
+55 -2
View File
@@ -50,12 +50,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Preferences",
[],
None,
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences",
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences",
super::always,
|s, p| {
let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?;
let new = merge("prefs.set", cur, p, None)?;
s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?;
let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?;
prefs.default_units.check().map_err(|e| bad("prefs.set", e))?;
s.prefs = prefs;
s.prefs.trim_recent_files();
serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))
}
@@ -100,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu
match (c.get_mut(k), v) {
(Some(Value::Object(old)), Value::Object(v)) => {
for (k2, v2) in v {
if !old.is_empty() && !old.contains_key(k2) {
return Err(bad(cmd, format!("unknown preference {k}.{k2}")));
}
old.insert(k2.clone(), v2.clone());
}
}
@@ -127,6 +132,7 @@ mod tests {
s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap();
assert!(s.prefs.show_font_names_in_english);
assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err());
assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err());
s.execute("file.new", &json!({})).unwrap();
let r = s
.execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}}))
@@ -141,6 +147,53 @@ mod tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas);
}
/// Units & Increments set with no document open are what new documents start with, as in
/// InDesign: a reader who works in inches sets them once, not in every file.
#[test]
fn default_units_are_what_new_documents_start_with() {
use designcraft_geom::Unit;
let mut s = Session::new();
let r = s
.execute(
"prefs.set",
&json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}),
)
.unwrap();
assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches");
assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept");
s.execute("file.new", &json!({})).unwrap();
let st = s.doc().unwrap().doc.settings.clone();
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches));
assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page));
assert!(!s.doc().unwrap().is_dirty(), "made so, not edited");
// Inches across, millimetres down: New Document's units (the horizontal ones) keep the
// vertical preference; other units picked there set both rulers.
s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap();
s.execute("file.new", &json!({"units": "inches"})).unwrap();
let st = s.doc().unwrap().doc.settings.clone();
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters));
s.execute("file.new", &json!({"units": "points"})).unwrap();
let st = s.doc().unwrap().doc.settings.clone();
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points));
// A preset keeps its own units, both of them, even when the default horizontal units are
// the preset's.
s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap();
s.execute("file.new", &json!({"preset": "A4"})).unwrap();
let st = s.doc().unwrap().doc.settings.clone();
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters));
assert_eq!(st.keyboard_increment, 0.5, "the increments still apply");
s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap();
// New Document opens with them; bad values are refused and change nothing.
assert_eq!(s.default_new_document().units, Unit::Inches);
for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] {
assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}");
}
assert_eq!(s.prefs.default_units.keyboard_increment, 0.5);
// Kept with the application preferences.
let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap();
assert_eq!(saved.default_units, s.prefs.default_units);
}
}
#[cfg(test)]
+132 -6
View File
@@ -81,13 +81,91 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words
/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted,
/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers").
const COMMON: &[&str] = &[
"ah",
"bitmap",
"blog",
"bluetooth",
"chatbot",
"checkbox",
"cybersecurity",
"database",
"dataset",
"desktop",
"download",
"dr",
"dropdown",
"ebook",
"ecommerce",
"eh",
"email",
"emoji",
"firewall",
"gigabyte",
"hashtag",
"hm",
"hmm",
"homepage",
"hyperlink",
"if",
"inbox",
"internet",
"jr",
"kilobyte",
"laptop",
"livestream",
"login",
"logout",
"malware",
"megabyte",
"metadata",
"mr",
"offline",
"ok",
"online",
"ow",
"pm",
"podcast",
"popup",
"printout",
"screenshot",
"selfie",
"smartphone",
"smartwatch",
"spreadsheet",
"sr",
"startup",
"terabyte",
"touchscreen",
"um",
"upload",
"us",
"username",
"voicemail",
"vs",
"we",
"webinar",
"webmaster",
"webpage",
"website",
"wifi",
"workflow",
"yay",
];
/// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems?
pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool {
if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) {
// A typographer's apostrophe (don’t, we’re) is the word list's straight one.
let w = &w.replace('’', "'");
let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok();
if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) {
return true;
}
let w = w.trim_end_matches("'s").trim_end_matches("’s");
if dict.get(w).is_some() {
let w = w.trim_end_matches("'s");
if has(w) {
return true;
}
let stems: [(&str, &[&str]); 9] = [
@@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool {
if let Some(base) = w.strip_suffix(suf) {
for add in adds {
let cand = format!("{base}{add}");
if cand.len() > 1 && dict.get(&cand).is_some() {
if cand.len() > 1 && has(&cand) {
return true;
}
// Doubled consonant: "stopped" → "stop".
if let Some(c) = base.chars().last()
&& base.len() > 2
&& base.ends_with(&format!("{c}{c}"))
&& dict.get(&base[..base.len() - c.len_utf8()]).is_some()
&& has(&base[..base.len() - c.len_utf8()])
{
return true;
}
@@ -128,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |c: Vec<char>| {
let c: String = c.into_iter().collect();
if c != w && !out.contains(&c) && dict.get(&c).is_some() {
if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) {
out.push(c);
}
};
@@ -238,6 +316,7 @@ mod tests {
}
assert!(!known(d, &[], "typograpy"));
assert!(suggest(d, "typograpy").contains(&"typography".to_string()));
assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too");
assert!(known(d, &["designcraft".into()], "designcraft"));
}
@@ -253,6 +332,53 @@ mod tests {
assert_eq!(bad, vec![6..10]);
}
/// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…),
/// with their inflections, and neither are contractions typed with a typographer's
/// apostrophe, which Use Typographer's Quotes turns ' into.
#[test]
fn common_words_and_curly_apostrophes_are_known() {
let d = Dictionary::en_us();
if d.is_empty() {
return; // data not bundled in this build
}
assert!(COMMON.is_sorted(), "COMMON is binary-searched");
for w in [
"we",
"us",
"if",
"ok",
"mr",
"email",
"emails",
"emailed",
"online",
"website",
"websites",
"downloaded",
"database",
"databases",
"blogger",
"don’t",
"can’t",
"i’m",
"we’re",
"they’ve",
"it’s",
"don't",
] {
assert!(known(d, &[], w), "{w}");
}
for w in ["dont", "wee’re", "emial"] {
assert!(!known(d, &[], w), "{w}");
}
// A user word added with a typographer's apostrophe matches either form.
assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s"));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap();
assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([]));
}
#[test]
fn check_finds_misspellings() {
let mut s = Session::new();
+2 -2
View File
@@ -17,7 +17,7 @@ use object::{apply_object, clear_object_overrides, create_object, edit_object};
use paragraph::{
apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine,
};
pub(crate) use paragraph::{check_grep_styles, grep_style_warnings};
pub(crate) use paragraph::{apply_char_style, apply_para_style, check_grep_styles, grep_style_warnings};
pub use swatches::parse_color;
use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options};
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
"{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
has_doc,
edit_para
),
+84 -23
View File
@@ -328,7 +328,7 @@ fn apply_para_in_story(
f.chars.retain_differences(style_chars);
}
});
for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) {
for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) {
let Some(pf) = st.paras.get(pi) else { continue };
let (_, base) = styles.resolve_para(pf);
let end = range.end.saturating_add(1).min(st.len());
@@ -345,6 +345,13 @@ fn apply_para_in_story(
}
}
/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their
/// text's overrides that the style now gives go (all of them with `clear`).
pub(crate) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>, clear: bool) {
let (style_para, style_chars) = styles.resolve_para_style(name);
apply_para_in_story(st, r, name, clear, (&style_para, &style_chars), styles);
}
pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string();
let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false);
@@ -374,25 +381,31 @@ pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
let styles = d.styles.clone();
for t in &targets {
if let Some(st) = d.text_story_mut(t.story, t.cell) {
for (pi, range) in st.para_ranges().into_iter().enumerate() {
let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end);
if range.is_empty() {
continue;
}
let Some(pf) = st.paras.get(pi) else { continue };
let (_, mut inherited) = styles.resolve_para(pf);
inherited.apply(&styles.char_style_attrs(&name));
st.format_chars(range, |f| {
f.style = name.clone();
f.over.retain_differences(&inherited);
});
}
apply_char_style(st, &styles, &name, &t.range);
}
}
ok()
})
}
/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its
/// paragraph) now gives go.
pub(crate) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>) {
for (pi, range) in st.paras_touching(r.clone()) {
let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end);
if range.is_empty() {
continue;
}
let Some(pf) = st.paras.get(pi) else { continue };
let (_, mut inherited) = styles.resolve_para(pf);
inherited.apply(&styles.char_style_attrs(name));
st.format_chars(range, |f| {
f.style = name.to_string();
f.over.retain_differences(&inherited);
});
}
}
/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit
/// "continue" so it also overrides a Start At the style would inherit.
fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
@@ -403,8 +416,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
}
}
/// The style `p[key]` names: none when absent or null; anything but a name is refused.
fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) => Ok(Some(n.clone())),
Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))),
}
}
pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
let based_on = style_name_param("style.paragraph.create", p, "basedOn")?;
let next = style_name_param("style.paragraph.create", p, "nextStyle")?;
let mut para: ParaAttrs =
attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?;
continue_numbering(p.get("para"), &mut para);
@@ -428,8 +452,6 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
chars = full;
}
}
let based_on = str_param(p, "basedOn").map(str::to_string);
let next = str_param(p, "nextStyle").map(str::to_string);
s.edit(|d, _| {
let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base);
if let Some(b) = &based_on
@@ -437,6 +459,13 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad("style.paragraph.create", format!("no style `{b}`")));
}
// Next Style: a style of the document, or this one.
if let Some(n) = &next
&& *n != name
&& d.styles.para(n).is_none()
{
return Err(bad("style.paragraph.create", format!("no style `{n}`")));
}
d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() });
Ok(json!({"name": name}))
})
@@ -499,11 +528,41 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string);
let based = p.get("basedOn").cloned();
let next = p.get("nextStyle").cloned();
let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false);
for (key, v) in [("basedOn", &based), ("nextStyle", &next)] {
if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) {
return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null")));
}
}
s.edit(|d, _| {
if let Some(Value::String(b)) = &based
&& d.styles.para_based_on_cycles(&name, b)
if let Some(Value::String(b)) = &based {
if d.styles.para(b).is_none() {
return Err(bad("style.paragraph.edit", format!("no style `{b}`")));
}
if d.styles.para_based_on_cycles(&name, b) {
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
}
}
if let Some(Value::String(n)) = &next
&& d.styles.para(n).is_none()
{
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
return Err(bad("style.paragraph.edit", format!("no style `{n}`")));
}
// Reset To Base: what the style set of its own goes before the edits apply.
if reset {
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para = ParaAttrs::default();
st.chars = CharAttrs::default();
}
// A new Based On first, so the fields below resolve over the new parent.
if based.is_some() {
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
match &based {
Some(Value::String(b)) => st.based_on = Some(b.clone()),
Some(Value::Null) => st.based_on = None,
_ => {}
}
}
if let Some(n) = &rename {
if n.is_empty() || n.starts_with('[') {
@@ -522,9 +581,9 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
match based {
Some(Value::String(b)) => st.based_on = Some(b),
Some(Value::Null) => st.based_on = None,
match next {
Some(Value::String(n)) => st.next_style = Some(n),
Some(Value::Null) => st.next_style = None,
_ => {}
}
if let Some(n) = rename.clone() {
@@ -781,7 +840,7 @@ pub(super) fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
chars = captured;
}
// Based on [None] is based on nothing.
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE);
s.edit(|d, _| {
if let Some(b) = &based_on
&& d.styles.char_style(b).is_none()
@@ -1141,6 +1200,7 @@ mod delete_tests {
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
/// Deleting a paragraph style also redirects TOC style references to its replacement.
#[test]
fn deleting_a_paragraph_style_points_every_use_at_the_replacement() {
let mut s = Session::new();
@@ -1162,6 +1222,7 @@ mod delete_tests {
entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }],
page_numbers: false,
title_style: Some("Table Body".into()),
create_bookmarks: false,
});
Ok(())
})
+2 -1
View File
@@ -28,4 +28,5 @@ mod styles;
mod target;
pub use registry::specs;
pub(crate) use selection::step_cell;
pub(crate) use selection::{one_selected_cell_to_text, step_cell};
pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS};
+17 -7
View File
@@ -1,7 +1,7 @@
use super::cell::{get_cell_stroke, set_cell};
use super::content::{drop_cells, place_graphic, sort_rows};
use super::options::{get, options};
use super::selection::{move_rc, select, step_cell};
use super::selection::{move_rc, select, step_cell, toggle_selection};
use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height};
use super::structure::{
can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row,
@@ -82,8 +82,9 @@ fn table_specs() -> Vec<CommandSpec> {
"table.insert",
"Create Table",
[],
Some("Cmd+Alt+Shift+T"),
"{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
// InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`).
None,
"{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step",
has_text,
insert
),
@@ -92,7 +93,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Convert Text to Table",
["Table"],
None,
"{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself",
has_text,
convert_from_text
),
@@ -101,7 +102,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Convert Table to Text",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text",
in_table,
convert_to_text
),
@@ -207,7 +208,7 @@ fn table_specs() -> Vec<CommandSpec> {
cmd!(
"table.placeGraphic",
"Convert Cell to Graphic Cell",
["Table", "Convert Cell Type"],
["Table"],
None,
"{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)",
in_table,
@@ -216,7 +217,7 @@ fn table_specs() -> Vec<CommandSpec> {
cmd!(
"table.textCell",
"Convert Cell to Text Cell",
["Table", "Convert Cell Type"],
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics",
in_table,
@@ -397,6 +398,15 @@ fn table_specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
cmd!(
noundo "table.toggleSelection",
"Toggle Text and Cell Selection",
[],
None,
"{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one",
in_cell,
|s, _| toggle_selection(s)
),
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
]
}
+49
View File
@@ -66,6 +66,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
Ok(json!({"row": row, "col": col}))
}
/// With one cell selected (a merged one counts as one), select its whole text instead, so what is
/// typed next replaces it, as InDesign does. Other selections are left as they are.
pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> {
let st = s.doc_mut()?;
let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) };
let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) };
let (row, col) = tb.owner(cs.range.r0, cs.range.c0);
if tb.owner(cs.range.r1, cs.range.c1) != (row, col) {
return Ok(());
}
let cell = CellAddr { table: cs.table, row, col };
let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());
st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });
Ok(())
}
/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of
/// the first one (InDesign toggles between the two).
pub(super) fn toggle_selection(s: &mut Session) -> Result<Value> {
const ID: &str = "table.toggleSelection";
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?;
let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?;
let cells = match st.selection.cells {
Some(cs) => {
// A merged cell's text belongs to the cell at its top left.
let (row, col) = st
.doc
.story(cs.story)
.and_then(|x| x.tables.get(&cs.table))
.map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0));
let cell = CellAddr { table: cs.table, row, col };
let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());
st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });
false
}
None => {
st.selection = Selection {
cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }),
text: Some(TextSel { anchor: 0, focus: 0, ..t }),
..Default::default()
};
true
}
};
st.bump_revision();
Ok(json!({"cells": cells}))
}
/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col).
pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> {
let st = s.active()?;
+93 -11
View File
@@ -1,6 +1,31 @@
use super::target::{column_width, count, edit_table, in_cell, para_direction, target};
use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param};
/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table
/// dialog's limits too).
pub const MAX_BODY_ROWS: u64 = 500;
pub const MAX_COLUMNS: u64 = 200;
pub const MAX_HEADER_FOOTER_ROWS: u64 = 100;
/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert`
/// makes, however the rows and columns divide them (a long list can make a long table).
pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS;
/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the
/// resolved style and the cell styles it names. Anything but an existing style's name is an error.
fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<(designcraft_doc::TableStyle, Vec<designcraft_doc::CellStyle>)>> {
let name = match p.get("tableStyle") {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None),
Some(Value::String(n)) => n,
Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")),
};
let styles = &s.doc()?.doc.styles;
if !styles.table.iter().any(|x| x.name == *name) {
return Err(bad(cmd, format!("no table style `{name}`")));
}
Ok(Some((styles.resolve_table_style(name), styles.cell.clone())))
}
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
if t.cell.is_some() {
@@ -20,10 +45,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
},
}
};
let rows = given("rows", None, 4, 1..=500)?;
let cols = given("cols", Some("columns"), 4, 1..=200)?;
let header = given("headerRows", None, 0, 0..=100)?;
let footer = given("footerRows", None, 0, 0..=100)?;
let rows = given("rows", None, 4, 1..=MAX_BODY_ROWS)?;
let cols = given("cols", Some("columns"), 4, 1..=MAX_COLUMNS)?;
let header = given("headerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
let footer = given("footerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
let style = table_style_param(s, p, "table.insert")?;
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
@@ -37,6 +63,9 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
c.text.chars[0].format = cf.clone();
}
if let Some((ts, cells)) = &style {
ts.apply_to(&mut table, cells);
}
let r = t.range();
st.delete(r.clone());
st.insert_table(r.start, table);
@@ -45,12 +74,41 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
})
}
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
if t.cell.is_some() {
return Err(bad("table.convertFromText", "select text outside tables"));
/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the
/// separator's own text (InDesign's fields take any characters).
fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result<String> {
let sep = match p.get(key) {
None | Some(Value::Null) => return Ok(default.to_string()),
Some(Value::String(s)) => match s.as_str() {
"tab" => "\t",
"comma" => ",",
"paragraph" => "\n",
other => other,
},
Some(_) => "",
};
if sep.is_empty() {
return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters")));
}
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
Ok(sep.to_string())
}
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.convertFromText";
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
if t.cell.is_some() {
return Err(bad(ID, "select text outside tables"));
}
let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?);
// With one separator for both, InDesign fills rows of Number of Columns cells.
let per_row = match p.get("columns") {
None | Some(Value::Null) => 1,
Some(v) => v
.as_u64()
.filter(|n| (1..=MAX_COLUMNS).contains(n))
.ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize,
};
let style = table_style_param(s, p, ID)?;
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
@@ -62,11 +120,33 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("table.convertFromText", "the selection contains a table"));
}
let (a, b) = (ranges[pa].start, ranges[pb].end);
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
let text = &st.text[a..b];
let data: Vec<Vec<String>> = if col_sep == row_sep {
let cells: Vec<String> = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect();
cells.chunks(per_row).map(<[String]>::to_vec).collect()
} else {
text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect()
};
// The table is as wide as its widest row; refuse what no table here could hold.
let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64;
if cols > MAX_COLUMNS {
return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}")));
}
if cols * data.len() as u64 > MAX_CONVERTED_CELLS {
return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells")));
}
// `Table::new` would clamp the shape and drop the rows past it, with their text.
let (rows, _) = designcraft_doc::clamped_size(data.len(), cols as usize);
if rows < data.len() {
return Err(bad(ID, format!("the text makes {} rows; a table of {cols} columns has at most {rows}", data.len())));
}
let pf = st.paras[pa].clone();
let cf = st.format_after(a).clone();
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
table.options.direction = direction;
if let Some((ts, cells)) = &style {
ts.apply_to(&mut table, cells);
}
st.delete(a..b);
let anchor = st.insert_table(a, table);
*sel = Selection::text(TextSel::caret(t.story, anchor));
@@ -76,9 +156,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.convertToText")?;
let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?;
let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?;
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default();
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
+7 -1
View File
@@ -25,7 +25,13 @@ pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> {
pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }
// Footnote text is addressed as a cell too, but isn't in a table.
let in_table = |c: CellAddr| c.footnote_id().is_none();
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) {
Ok(())
} else {
Err("not in a table cell".into())
}
}
/// The table a command acts on and its target cell range.
+8 -3
View File
@@ -191,12 +191,17 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the UTF-8 byte offset into `text` where it stops fitting its frames, not a character index, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let cs = s.cache.get(&st.doc, sid, None);
// `overset` is where the text stops fitting, not how much is left over: the two were
// read as the same number and disagreed with preflight's count (#1465).
v["overset"] = json!(cs.overset_at);
v["oversetChars"] = json!(cs
.overset_at
.map(|at| st.doc.stories.get(&sid).and_then(|story| story.text.get(at..)).unwrap_or("").chars().count()));
v["lines"] = json!(cs.line_count());
Ok(v)
}),
@@ -206,7 +211,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character Formatting",
[],
None,
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)",
has_text_or_frames,
|s, p| format_chars(s, p.get("attrs").unwrap_or(p))
),
@@ -420,7 +425,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Selection Attributes",
[],
None,
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
has_doc,
selection_attrs
),
+2
View File
@@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
s.typing_format = None;
let stm = s.doc_mut()?;
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
// Moving the caret leaves the selected cells, so typing inserts at the caret.
stm.selection.cells = None;
stm.bump_revision();
Ok(json!({"pos": new}))
}
+5 -2
View File
@@ -13,10 +13,13 @@ fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> R
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = crate::cmd::text_param(p, "text");
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) {
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). Footnote text is
// addressed as a cell too: a Tab there is typed.
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) {
return crate::cmd::table::step_cell(s, true);
}
// Typing with one cell selected replaces its text, as InDesign.
crate::cmd::table::one_selected_cell_to_text(s)?;
let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false);
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
let tracking = s.doc()?.doc.settings.track_changes;
+57 -2
View File
@@ -162,6 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
}
}
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
let warnings = missing_font_warnings(&s.doc()?.doc, &a);
let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) };
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
// A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it
@@ -185,14 +187,33 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
})?;
}
s.typing_format = Some(f);
return ok();
return Ok(result);
}
s.edit(|d, _| {
apply_char_attrs(d, &targets, &a, &cleared);
ok()
Ok(result)
})
}
/// A font family or style the app doesn't have is still applied, so the document keeps its name
/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's
/// words, instead of succeeding silently.
fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec<String> {
let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else {
return vec![];
};
let db = designcraft_fonts::FontDb::global().scoped(document.font_scope);
let style = attrs.font_style.as_deref().unwrap_or("Regular");
let face = db.face(family, style);
if !db.has_family(family) {
vec![format!("Missing font: {family} (using {})", face.family)]
} else if attrs.font_style.is_some() && !db.has_style(family, style) {
vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)]
} else {
vec![]
}
}
/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the
/// character before it. With no such pair (story or paragraph start, a selection) this is
/// `type.char`.
@@ -1073,3 +1094,37 @@ mod size_bounds_tests {
}
}
}
#[cfg(test)]
mod missing_font_tests {
use serde_json::json;
use crate::Session;
/// A font the app doesn't have was applied with no word about it, so a script that asked for
/// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight
/// and Find Font list it), and the command now says which font is missing.
#[test]
fn a_font_that_is_not_available_is_applied_with_a_warning() {
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();
for (attrs, warning) in [
(json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None),
(json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")),
(json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")),
] {
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap();
match warning {
None => assert_eq!(r, json!(null), "{attrs}"),
Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"),
}
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}");
}
// At a caret the font is for the next typing, and the warning is the same.
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap();
let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap();
assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}");
}
}
+4 -1
View File
@@ -125,9 +125,12 @@ pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
let char_delta = character_base.delta(&cp);
let style_keys = character_style.keys();
let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count();
// The text's own formatting over its paragraph, as Create New Style from Selection captures it.
let mut own = character_style.clone();
own.merge(&cf.over);
// The selected text (its start, on one line), for previews such as the font menu's.
let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars());
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "ownChars": own,
"paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(),
"charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides,
"selectedText": selected.trim()});
+68 -2
View File
@@ -12,6 +12,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
/// Register TOC generation and refresh commands.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -19,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Table of Contents…",
["Layout"],
None,
"{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
"{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted, which is off for a new table), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
has_doc,
generate
),
@@ -302,9 +303,30 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() });
}
}
let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len());
d.bookmarks.retain(|b| !b.generated_by_toc);
if toc.create_bookmarks {
d.bookmarks.splice(insertion..insertion, bookmarks(&found));
}
Ok(found.len())
}
/// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when
/// a document skips a level; each destination is the physical page, not its section label.
fn bookmarks(found: &[Found]) -> Vec<designcraft_doc::Bookmark> {
/// Collect descendants until the next sibling or ancestor entry.
fn children(iter: &mut std::iter::Peekable<std::slice::Iter<'_, Found>>, parent: u8) -> Vec<designcraft_doc::Bookmark> {
let mut out = Vec::new();
while iter.peek().is_some_and(|f| f.level > parent) {
let Some(f) = iter.next() else { break };
out.push(designcraft_doc::Bookmark { name: f.text.clone(), page: f.key.0, children: children(iter, f.level), generated_by_toc: true });
}
out
}
children(&mut found.iter().peekable(), 0)
}
/// Validate the TOC settings and generate its frame, text, and outline in one edit.
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
let entries = entries_param(p)?;
if entries.is_empty() {
@@ -320,6 +342,10 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters")));
}
let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true);
let create_bookmarks = match p.get("createBookmarks") {
Some(v) => v.as_bool().ok_or_else(|| bad("toc.generate", "createBookmarks must be a boolean"))?,
None => s.doc()?.doc.toc.as_ref().is_some_and(|t| t.create_bookmarks),
};
let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true);
let rect = super::rect_param(p, "rect");
if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) {
@@ -345,7 +371,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
(sid, w)
}
};
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() };
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone(), create_bookmarks };
let n = fill(d, &toc, width)?;
d.toc = Some(toc);
Ok(json!({"story": story.0, "entries": n}))
@@ -623,6 +649,46 @@ mod tests {
assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s));
}
/// Refresh preserves manual outline positions, nested destinations, storage, and undo.
#[test]
fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() {
let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]);
s.execute("layout.section", &json!({"page": 1, "style": "lowerRoman", "startNumber": 4})).unwrap();
s.execute("bookmark.add", &json!({"name": "Manual", "page": page})).unwrap();
let p = json!({"page": page, "createBookmarks": true, "entries": [
{"style": "Head", "level": 1}, {"style": "Sub", "level": 3}
]});
s.execute("toc.generate", &p).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.bookmarks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Manual", "First", "Last"]);
assert_eq!(d.bookmarks[1].page, 0, "physical page, although its label is iv");
assert_eq!(d.bookmarks[1].children[0].name, "Child");
assert!(d.bookmarks[1].children[0].generated_by_toc);
let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap();
s.execute("bookmark.add", &json!({"name": "Manual after", "page": page})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap();
s.execute("text.insert", &json!({"text": "Changed"})).unwrap();
assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit");
s.execute("toc.update", &json!({})).unwrap();
let refreshed = s.doc().unwrap().doc.bookmarks.clone();
assert_eq!(refreshed.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Manual", "Changed", "Last", "Manual after"]);
s.execute("toc.update", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks");
// Native storage retains both the option and ownership; old bookmarks default to manual.
let stored = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let back: Document = serde_json::from_value(stored).unwrap();
assert!(back.toc.as_ref().unwrap().create_bookmarks);
assert_eq!(back.bookmarks, refreshed);
let old: designcraft_doc::Bookmark = serde_json::from_value(json!({"name": "Old", "page": 0})).unwrap();
assert!(!old.generated_by_toc);
let mut disabled = p;
disabled["createBookmarks"] = json!(false);
s.execute("toc.generate", &disabled).unwrap();
assert_eq!(s.doc().unwrap().doc.bookmarks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Manual", "Manual after"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed);
}
#[test]
fn toc_entry_options_round_trip_through_the_native_format() {
let mut e = TocEntry::new("Head", 2);
+21 -2
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Text Variable…",
["Type", "Text Variables"],
None,
"{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text?, format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name",
"{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text? (a string; a number or a boolean is shown as typed, anything else is refused), format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name",
has_doc,
define
),
@@ -391,11 +391,30 @@ fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth
}
}
/// A parameter's text: a string as it is; a number or a boolean spelled out. A Custom variable's
/// text typed as `2026` reaches the command as a number (the dialog and JSON callers type their
/// values), and it is still the text the variable shows (#2093).
fn text_param(p: &Value, key: &str) -> Option<String> {
match p.get(key)? {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn define(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string();
let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string();
let kind = match str_param(p, "type").unwrap_or("custom") {
"custom" => VarKind::Custom { text: str_param(p, "text").unwrap_or("").into() },
"custom" => VarKind::Custom {
// No `text` is empty text; an object or an array is refused rather than stored as
// empty text in place of what a variable of that name holds.
text: match p.get("text") {
None | Some(Value::Null) => String::new(),
Some(_) => text_param(p, "text").ok_or_else(|| bad("variables.define", "`text` must be a string, a number or a boolean"))?,
},
},
"lastPageNumber" => VarKind::LastPageNumber { section: p.get("section").and_then(Value::as_bool).unwrap_or(false) },
"chapterNumber" => VarKind::ChapterNumber,
"fileName" => VarKind::FileName { extension: p.get("extension").and_then(Value::as_bool).unwrap_or(false) },
+40
View File
@@ -1,5 +1,10 @@
use super::*;
fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize {
let cs = s.cache.get(d, sid, None);
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
}
/// A parent-page page-number marker follows Numbering & Section Options: changing a section's
/// start number renames its pages without changing their index or the number of sections, and
/// the marker composed for a page shows the new name.
@@ -350,6 +355,14 @@ fn running_header_on_parent_page_follows_headings() {
assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon");
let list = s.execute("variables.list", &json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3"));
// On the parent page itself the header shows which variable it holds, not a blank. Text with
// no page that isn't on a parent shows the variable's text before and after, as before.
let head = d.variable("Chapter Head").unwrap();
assert_eq!(crate::compose::vars::values_shown(&d, None, true, Some(&idx))[head], "<Chapter Head>");
assert_eq!(crate::compose::vars::values_shown(&d, None, false, Some(&idx))[head], "");
assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], "", "values() is the no-parent case");
assert_eq!(crate::compose::vars::values_shown(&d, Some(2), true, Some(&idx))[head], "Epsilon", "composed for a page: its entry");
assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows <Chapter Head>, longer than Alpha");
// Deleting a variable flattens its instances to text.
s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap();
assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3"));
@@ -421,3 +434,30 @@ fn running_header_orders_stories_by_their_first_heading() {
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Beta");
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma");
}
/// #2093: a Custom variable whose text arrives as a number (the dialog types a typed `2026` as
/// one) shows those digits; it showed nothing at all before.
#[test]
fn a_custom_variable_defined_with_a_number_shows_its_digits() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": 2026})).unwrap();
s.execute("variables.define", &json!({"name": "Half", "type": "custom", "text": 0.5})).unwrap();
s.execute("variables.define", &json!({"name": "Word", "type": "custom", "text": "year"})).unwrap();
s.execute("variables.define", &json!({"name": "Flag", "type": "custom", "text": true})).unwrap();
// Anything else is refused, and a variable of that name keeps its text; no text is empty text.
assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": {"y": 2026}})).is_err());
assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": [2026]})).is_err());
s.execute("variables.define", &json!({"name": "Blank", "type": "custom"})).unwrap();
let list = s.execute("variables.list", &json!({})).unwrap();
let value = |name: &str| list.as_array().unwrap().iter().find(|v| v["name"] == name).unwrap()["value"].as_str().unwrap().to_string();
assert_eq!((value("Year"), value("Half"), value("Word")), ("2026".to_string(), "0.5".to_string(), "year".to_string()));
assert_eq!((value("Flag"), value("Blank")), ("true".to_string(), String::new()));
// Inserted into a story, the instance composes as four digits.
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("variables.insert", &json!({"name": "Year"})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert_eq!(shown_on_parent(&s, &d, sid), 4, "2026 shows");
}
+29 -1
View File
@@ -168,7 +168,7 @@ fn parse(xml: &str) -> std::result::Result<Vec<Element>, String> {
_ => String::new(),
};
match (depth, child.as_mut(), out.last_mut()) {
(3, Some(c), _) => c.1.push_str(&s),
(3.., Some(c), _) => c.1.push_str(&s),
(2, _, Some(el)) => el.text.push_str(&s),
_ => {}
}
@@ -518,6 +518,34 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.xml.style_map.len(), 1);
}
#[test]
fn import_xml_keeps_text_of_inline_tags_in_mapped_paragraphs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
let a = s
.execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "See the glossary entry", "caret": false}))
.unwrap();
s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap();
s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap();
s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "title"})).unwrap();
s.execute("text.select", &json!({"story": a["story"], "anchor": 8, "focus": 16})).unwrap();
s.execute("xml.tagText", &json!({"tag": "term"})).unwrap();
let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(x.contains("<title>See the <term>glossary</term> entry</title>"), "{x}");
s.execute("file.importXml", &json!({"text": x})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.stories[&designcraft_doc::StoryId(a["story"].as_u64().unwrap())].text, "See the glossary entry");
}
#[test]
fn parse_keeps_text_nested_below_a_child_element() {
let els = super::parse("<root><story><note>See the <term>x</term> y</note></story></root>").unwrap();
assert_eq!(els.len(), 1);
assert_eq!(els[0].children, vec![("note".to_string(), "See the x y".to_string())]);
}
#[test]
fn tag_export_and_import_xml() {
let mut s = Session::new();
+5 -2
View File
@@ -94,7 +94,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let r = t2.range();
let r = r.start.min(st.len())..r.end.min(st.len());
st.delete(r.clone());
st.insert_xref(r.start, CrossRef { target, format });
st.insert_xref(r.start, CrossRef { target, format, ..Default::default() });
let pos = r.start + designcraft_doc::XREF_MARK.len_utf8();
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t2 });
Ok(json!({"target": target, "pos": r.start}))
@@ -169,7 +169,10 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
}
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let x = st.xrefs.get_mut(k).ok_or_else(|| bad("xref.setFormat", "no such cross-reference"))?;
std::sync::Arc::make_mut(x).format = format;
let x = std::sync::Arc::make_mut(x);
x.format = format;
// A new format generates the text again, replacing any edits (InDesign does the same).
x.text = None;
st.rev = st.rev.wrapping_add(1);
Ok(Value::Null)
})
+1 -1
View File
@@ -1,4 +1,4 @@
//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command —
//! Last-resort guard: a panic that escapes a command —
//! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an
//! error, and the document is kept as it was before the command (and saved for crash recovery
//! when a recovery directory is set). It is a safety net: commands still must not panic.
+61
View File
@@ -250,6 +250,61 @@ pub struct Prefs {
pub recent_files: Vec<String>,
/// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list).
pub recent_files_max: u32,
/// Preferences › Units & Increments set with no document open: what new documents start
/// with, as InDesign's application defaults. A document keeps its own once made.
pub default_units: DefaultUnits,
}
/// Units & Increments for new documents (Preferences with no document open), in the keys
/// `document.preferences` uses.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DefaultUnits {
pub horizontal_units: designcraft_geom::Unit,
pub vertical_units: designcraft_geom::Unit,
pub ruler_origin: designcraft_doc::RulerOrigin,
/// Cursor Key, in points.
pub keyboard_increment: f64,
/// Kerning/Tracking, in 1/1000 em.
pub kerning_increment: f64,
}
impl Default for DefaultUnits {
/// A new document's own settings.
fn default() -> Self {
let d = designcraft_doc::DocSettings::default();
DefaultUnits {
horizontal_units: designcraft_geom::Unit::Picas,
vertical_units: designcraft_geom::Unit::Picas,
ruler_origin: d.ruler_origin,
keyboard_increment: d.keyboard_increment,
kerning_increment: d.kerning_increment,
}
}
}
impl DefaultUnits {
/// Gives a new document these defaults. Unless it was made from a preset (whose units are its
/// own), its vertical ruler follows the preference while it keeps the default horizontal
/// units (units picked in New Document set both rulers).
pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) {
if !preset && settings.horizontal_units == self.horizontal_units {
settings.vertical_units = self.vertical_units;
}
settings.ruler_origin = self.ruler_origin;
settings.keyboard_increment = self.keyboard_increment;
settings.kerning_increment = self.kerning_increment;
}
/// Refuses increments that aren't finite and positive (at most 1000).
pub fn check(&self) -> std::result::Result<(), String> {
for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] {
if !(v.is_finite() && v > 0.0 && v <= 1000.0) {
return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000"));
}
}
Ok(())
}
}
/// The most recent files Preferences lets File › Open Recent keep.
@@ -324,6 +379,7 @@ impl Default for Prefs {
link_text_files: false,
recent_files: Vec::new(),
recent_files_max: 20,
default_units: DefaultUnits::default(),
}
}
}
@@ -379,6 +435,10 @@ pub struct Session {
/// Linked text files checked by the Links panel: path → the size and modification time the
/// file had and the hash of its contents then, so a file touched but not changed is read once.
pub(crate) text_link_checks: std::collections::HashMap<String, (designcraft_doc::Fingerprint, String)>,
/// Linked images checked against their files this session (`links.list` polls twice a second):
/// a file of the recorded size and time isn't read again.
pub(crate) image_link_checks:
std::collections::HashMap<(designcraft_doc::AssetId, usize, std::path::PathBuf), (designcraft_doc::Fingerprint, bool)>,
}
impl Default for Session {
@@ -411,6 +471,7 @@ impl Session {
recovery_dir: None,
text_clipboard: None,
text_link_checks: Default::default(),
image_link_checks: Default::default(),
ui_requests: vec![],
loaded: None,
transforms: Default::default(),
+7 -1
View File
@@ -900,7 +900,13 @@ fn layout(f: &Fonts, n: &Node, size: f64) -> MBox {
pub fn to_svg(latex: &str, size: f64) -> (Vec<u8>, (f64, f64)) {
let db = FontDb::global();
let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") };
let b = layout(&f, &parse(latex), size);
// XML 1.0 forbids most control characters and U+FFFE/U+FFFF; drop them before layout so
// they take no width either.
let latex: String = latex
.chars()
.filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))
.collect();
let b = layout(&f, &parse(&latex), size);
let pad = size * 0.1;
let (w, h) = (b.w + 2.0 * pad, b.asc + b.desc + 2.0 * pad);
let base = pad + b.asc;
+9
View File
@@ -474,3 +474,12 @@ fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() {
});
assert!(result.is_err(), "the intentional panic must actually unwind the owned frames");
}
#[test]
fn svg_drops_control_characters_forbidden_by_xml() {
let (svg, _) = to_svg("a\u{1}b\u{c}", 12.0);
let svg = String::from_utf8(svg).unwrap();
assert!(!svg.chars().any(|c| c < ' ' && !matches!(c, '\t' | '\n' | '\r')), "control character in SVG: {svg:?}");
// The dropped characters take no width either.
assert_eq!(to_svg("a\u{1}b\u{c}", 12.0).1, to_svg("ab", 12.0).1);
}
+10 -2
View File
@@ -26,7 +26,8 @@ pub struct Step {
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
// Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the
// first command id.
let t = text.trim_start_matches('\u{feff}').trim();
let body = text.trim_start_matches('\u{feff}');
let t = body.trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
@@ -38,7 +39,8 @@ pub fn parse(text: &str) -> Result<Vec<Step>, String> {
.collect();
}
let mut out = Vec::new();
for (n, line) in t.lines().enumerate() {
// Number lines from the untrimmed text so leading blank lines still count.
for (n, line) in body.lines().enumerate() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') || l.starts_with("//") {
continue;
@@ -185,6 +187,12 @@ impl crate::Session {
mod tests {
use super::*;
#[test]
fn leading_blank_lines_keep_line_numbers() {
let e = parse("\n\n# c\nfile.new\nframe.create {bad").unwrap_err();
assert!(e.contains("line 5"), "{e}");
}
#[test]
fn parses_all_forms() {
let a = parse(r#"[{"command": "file.new"}, {"command": "frame.create", "params": {"rect": [0, 0, 10, 10]}}]"#).unwrap();
+349
View File
@@ -486,6 +486,63 @@ fn place_gun_click_drag_and_into_frame() {
assert_eq!(graphic_xf(&s), placed);
}
/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as
/// InDesign does, shows its scale, and places exactly that frame, in any direction.
#[test]
fn place_gun_drag_previews_and_places_a_proportional_frame() {
use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
let mut s = session();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap();
let v = ViewInfo::at_zoom(1.0);
let p = designcraft_geom::Point::new;
assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic);
// Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press.
s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap();
assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing");
let o = s.overlays(v);
assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}");
assert!(
o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00%
Y: 200.00%")),
"{o:?}"
);
s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap();
let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone();
assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed");
// 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for
// `place.drop`, which places at natural size: the preview shows that frame and 100 %.
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap();
let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0);
let o = s.overlays(v);
assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}");
assert!(
o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00%
Y: 100.00%")),
"{o:?}"
);
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap();
let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone();
assert_eq!(it.bounds(), natural, "the frame previewed");
// A drag under the threshold is a click: no frame drawn, the graphic at actual size where
// the press was.
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap();
assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet");
assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays");
s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap();
let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone();
assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press");
// With no graphic loaded there's no thumbnail to show: the crosshair, before any press too.
s.loaded = None;
s.set_tool("placeGun");
assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair);
}
#[test]
fn preflight_reports_overset_and_missing_fonts() {
let mut s = session();
@@ -877,6 +934,9 @@ fn custom_wrap_path_follows_resize_and_yields_to_offsets() {
s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap();
let w = wrap_path(&s).unwrap().bounds().unwrap();
assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}");
// Offsets that are all null change nothing, so the boundary stays.
s.execute("object.textWrap", &json!({"mode": null, "offset": [null, null, null, null]})).unwrap();
assert!(wrap_path(&s).is_some(), "an all-null offset keeps the custom boundary");
// An offset rebuilds the wrap from the frame.
s.execute("object.textWrap", &json!({"offset": 6})).unwrap();
assert_eq!(wrap_path(&s), None);
@@ -1661,6 +1721,295 @@ fn a_line_fits_when_its_baseline_does() {
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
}
#[test]
fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() {
use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
s.execute("layout.parents.new", &json!({"prefix":"B"})).unwrap();
s.execute("layout.parents.edit", &json!({"on":true})).unwrap();
s.set_tool("page");
let v = ViewInfo::at_zoom(1.0);
let before = s.doc().unwrap().doc.clone();
for si in 0..2 {
for pi in 0..2 {
let sr = designcraft_doc::SpreadRef::Parent(si);
let rect = |s: &Session| s.layout().spread_page_rect(&s.doc().unwrap().doc, sr, pi).unwrap();
let c = rect(&s).center();
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap();
assert_eq!(s.doc().unwrap().selection.parent_page, Some((before.parents[si].id, pi)));
assert_eq!(s.doc().unwrap().selection.page, None);
assert!(s.overlays(v).iter().any(|o| matches!(o,Overlay::PageHandles(r) if *r==rect(&s))));
for alt in [false, true] {
let original = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let r = rect(&s);
let p = designcraft_geom::Point::new(r.x1, r.center().y);
let ev = |kind, x| PointerEvent::new(kind, x, p.y).with_mods(Mods { alt, ..Default::default() });
s.pointer(&ev(PointerKind::Down, p.x), v).unwrap();
s.pointer(&ev(PointerKind::Drag, p.x + 80.0), v).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
assert_eq!(s.doc().unwrap().doc.spreads, before.spreads);
assert_eq!(s.doc().unwrap().doc.parents[1 - si], before.parents[1 - si]);
s.pointer(&ev(PointerKind::Up, p.x + 80.0), v).unwrap();
if alt {
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents, original.parents);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
s.execute("edit.undo", &json!({})).unwrap();
} else {
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &original));
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}
let c = rect(&s).center();
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, c.x, c.y), v).unwrap();
let req = std::mem::take(&mut s.ui_requests);
assert!(
matches!(req.last(),Some(crate::UiRequest::Dialog{id,params}) if id=="cmd:layout.parentPageSize" && params["parent"]==before.parents[si].id.0 && params["page"]==pi)
);
s.doc().unwrap().doc.check().unwrap();
}
}
}
/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the
/// old one.
#[test]
fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() {
let mut s = session();
let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset});
s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap();
s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}}))
.unwrap();
let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below;
assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's");
}
/// Paragraph style references are checked: a Based On or Next Style that names no style, or
/// that isn't a name or null, is refused and changes nothing.
#[test]
fn paragraph_style_references_must_name_styles() {
let mut s = session();
s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap();
assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err());
assert!(s.doc().unwrap().doc.styles.para("Lost").is_none());
// On creation too, a reference that isn't a name or null is refused, not dropped.
for p in [json!({"name": "Lost", "nextStyle": true}), json!({"name": "Lost", "basedOn": 3})] {
assert!(s.execute("style.paragraph.create", &p).is_err(), "{p}");
assert!(s.doc().unwrap().doc.styles.para("Lost").is_none(), "{p}");
}
assert!(s.execute("style.character.create", &json!({"name": "Lost", "basedOn": ["Body"]})).is_err());
assert!(s.doc().unwrap().doc.styles.char_style("Lost").is_none());
s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap();
let before = s.doc().unwrap().doc.clone();
for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] {
assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}");
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap();
}
/// The course's chapter 43: with Direct Selection, a circle's selected anchor shows its handles
/// and they drag, reshaping the curve; the handles of the anchors away from it neither show nor
/// catch a press, as in InDesign.
#[test]
fn direct_selection_drags_only_the_handles_around_the_selected_anchor() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let v = ViewInfo::at_zoom(1.0);
let id = s.execute("frame.create", &json!({"rect": [100, 300, 300, 500], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.set_tool("directSelection");
// Each anchor and its two handles, in the spread.
let anchors = |s: &Session| {
let st = s.doc().unwrap();
let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();
it.path.subpaths[0].anchors.iter().map(|a| (it.xf * a.p, it.xf * a.h_in, it.xf * a.h_out)).collect::<Vec<_>>()
};
let before = anchors(&s);
let lowest = |f: &dyn Fn(f64) -> f64| (0..before.len()).min_by(|a, b| f(before[*a].0.y).total_cmp(&f(before[*b].0.y))).unwrap();
let (bottom, top) = (lowest(&|y| -y), lowest(&|y| y));
let press = |s: &mut Session, from: designcraft_geom::Point, to: designcraft_geom::Point| {
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (from.x + to.x) / 2.0, (from.y + to.y) / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
};
// Select the bottom anchor.
let b = before[bottom].0;
press(&mut s, b, b);
assert_eq!(s.doc().unwrap().selection.anchors, [(designcraft_doc::ItemId(id), 0, bottom)]);
// Its handle drags: the handle moves.
let h = before[bottom].2;
press(&mut s, h, h + designcraft_geom::Vec2::new(30.0, 0.0));
let after = anchors(&s);
assert!((after[bottom].2 - before[bottom].2).hypot() > 20.0, "the bottom handle moved: {:?} → {:?}", before[bottom], after[bottom]);
// The top anchor's handle isn't shown, so a press on it doesn't take it.
let t = before[top].2;
press(&mut s, t, t + designcraft_geom::Vec2::new(0.0, -30.0));
let after2 = anchors(&s);
assert!((after2[top].2 - before[top].2).hypot() < 1e-6, "the top handle stays: {:?} → {:?}", before[top], after2[top]);
}
/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a
/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of
/// parallelograms).
#[test]
fn direct_selection_moves_the_points_of_several_frames_at_once() {
use designcraft_tools::{Mods, PointerEvent, PointerKind};
let mut s = session();
let v = ViewInfo::at_zoom(1.0);
let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": []})).unwrap();
s.set_tool("directSelection");
s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap();
let sel = s.doc().unwrap().selection.clone();
assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors);
assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b)));
let shift = Mods { shift: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap();
let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> {
let st = s.doc().unwrap();
let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();
it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect()
};
// Both frames: top points 36 pt to the right, bottom points where they were.
assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);
assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);
assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);
}
#[test]
fn selected_points_follow_their_path() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"anchors": [[a, 0, 1], [b, 0, 2]]})).unwrap();
// Shift-clicking a frame off the selection drops its points too.
s.execute("selection.toggle", &json!({"id": a})).unwrap();
let id = |n| designcraft_doc::ItemId(n);
assert_eq!(s.doc().unwrap().selection.anchors, vec![(id(b), 0, 2)]);
// A point added before the first one: the selected point keeps pointing at the same anchor.
let l = s.execute("path.create", &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100]}, {"p": [300, 150]}]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"anchors": [[l, 0, 1]]})).unwrap();
s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [50, 120]}, "at": "start"})).unwrap();
let st = s.doc().unwrap();
let (_, si, ai) = st.selection.anchors[0];
let it = st.doc.item(id(l)).unwrap();
assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0));
// `points` that isn't a list is refused, even with an `id` beside it.
for bad in [json!("x"), json!({"0": 1}), json!(5)] {
assert!(s.execute("path.moveAnchors", &json!({"points": bad, "id": l, "anchors": [[0, 0]], "dx": 5})).is_err(), "points {bad}");
}
for bad in [json!(3), json!(-1), json!("0")] {
assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}");
}
assert_eq!(s.doc().unwrap().doc.item(id(l)).unwrap().path.subpaths[0].anchors.len(), 4, "nothing appended");
}
#[test]
fn direct_selection_pulls_one_corner_of_an_unselected_frame() {
// Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside,
// then press on a frame's corner and pull it: only that corner moves (a triangle, nearly).
use designcraft_tools::{PointerEvent, PointerKind, SnapView};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
let corners = |s: &Session| {
let d = &s.doc().unwrap().doc;
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::<Vec<_>>()
};
let before = corners(&s);
s.set_tool("directSelection");
let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) };
let layout = s.layout();
let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y));
let (a, b) = (at(455.0, 219.0), at(455.0, 150.0));
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
let after = corners(&s);
let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect();
assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}");
assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}");
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected");
// One undo step puts the corner back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(corners(&s), before);
// Away from its corners, the White Arrow still drags the whole unselected frame.
s.execute("edit.deselectAll", &json!({})).unwrap();
let (c, d) = (at(300.0, 219.0), at(300.0, 269.0));
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap();
assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s));
}
#[test]
fn direct_selection_on_a_selected_frame_deletes_only_its_image() {
use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("photo.png");
std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap();
s.execute("selection.set", &json!({"ids": [frame], "content": false})).unwrap();
let v = ViewInfo::at_zoom(1.0);
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 110.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 150.0), v).unwrap();
};
// The frame is selected with the Selection tool, then the image is clicked with Direct Selection.
s.set_tool("selection");
click(&mut s);
assert!(!s.doc().unwrap().selection.content);
s.set_tool("directSelection");
click(&mut s);
assert!(s.doc().unwrap().selection.content, "Direct Selection selects the image inside the selected frame");
assert!(s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap());
let st = s.doc().unwrap();
let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays");
assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content);
assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]);
// A frame holding pasted-in items: the click selects them too, so arrow keys move them.
let inner = s.execute("frame.create", &json!({"rect": [320, 120, 360, 160]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [inner]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
let holder = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [holder]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
s.set_tool("selection");
s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap();
assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(holder)]);
assert!(!s.doc().unwrap().selection.content);
s.set_tool("directSelection");
s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap();
let st = s.doc().unwrap();
assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection);
}
/// 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]
+4 -2
View File
@@ -516,6 +516,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() {
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}
/// Imported TOC definitions retain their settings and remain refreshable.
#[test]
fn table_of_contents_definition_round_trips_through_idml_and_updates() {
let mut s = Session::new();
@@ -525,8 +526,8 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() {
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
s.execute(
"toc.generate",
&json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "},
&json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ \u{E006} "},
{"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"},
]}),
)
@@ -540,6 +541,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() {
let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap();
let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported");
assert!(after.create_bookmarks);
assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries));
assert_eq!(toc_text(&s), text);
s.execute("toc.update", &json!({})).unwrap();
+159 -1
View File
@@ -1,6 +1,7 @@
use serde_json::json;
use serde_json::{Value, json};
use super::{Session, doc};
use crate::Result;
fn session_with_frame() -> (Session, u64, u64) {
let mut s = Session::new();
@@ -127,6 +128,72 @@ fn table_edit_after_anchor_removal_floors_parent_caret() {
}
}
/// Footnote text is addressed as a cell, but the table commands don't take it for one.
#[test]
fn the_cell_commands_refuse_footnote_text() {
let (mut s, sid, _) = session_with_frame();
s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap();
s.execute("footnote.insert", &json!({"text": "Note"})).unwrap();
let before = s.doc().unwrap().selection.clone();
assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote");
for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] {
assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}");
}
assert_eq!(s.doc().unwrap().selection, before);
// A Tab there is typed into the footnote, not taken for the next cell.
s.execute("text.insert", &json!({"text": "\t"})).unwrap();
let notes = s.execute("footnote.list", &json!({})).unwrap();
assert!(notes[0]["text"].as_str().is_some_and(|t| t.contains('\t')), "{notes}");
}
/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell
/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell.
#[test]
fn escape_toggles_text_and_cell_selection() {
let (mut s, sid, _) = session_with_frame();
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
s.execute("table.nextCell", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "Phone"})).unwrap();
let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 };
assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true}));
let sel = s.doc().unwrap().selection.clone();
let cells = sel.cells.unwrap();
assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1)));
assert_eq!(sel.text.unwrap().cell, Some(cell));
assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false}));
let sel = s.doc().unwrap().selection.clone();
assert!(sel.cells.is_none());
let t = sel.text.unwrap();
assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected");
s.execute("table.prevCell", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell }));
// With the cell selected, typing replaces its text and leaves the caret after it.
s.execute("table.nextCell", &json!({})).unwrap();
s.execute("table.toggleSelection", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "Fax"})).unwrap();
let t = s.execute("table.get", &json!({"table": tid})).unwrap();
assert_eq!(t["cells"][1]["text"], "Fax");
let sel = s.doc().unwrap().selection.clone();
assert!(sel.cells.is_none());
assert_eq!(sel.text.unwrap().range(), 3..3);
// Moving the caret leaves the cell selection: typing inserts at the caret, not over the cell.
s.execute("table.toggleSelection", &json!({})).unwrap();
s.execute("text.move", &json!({"dir": "right"})).unwrap();
assert!(s.doc().unwrap().selection.cells.is_none());
s.execute("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][1]["text"], "F!ax");
// A selection that starts inside a merged cell goes back to the merged cell's text.
s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap();
s.execute("table.merge", &json!({})).unwrap();
s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap();
s.execute("table.toggleSelection", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell }));
// Outside a table there is nothing to toggle.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("table.toggleSelection", &json!({})).is_err());
}
fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) {
let (mut s, sid, fid) = session_with_frame();
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
@@ -426,6 +493,29 @@ fn rows_columns_merge_and_options() {
assert_eq!(st.doc.story(sid).unwrap().text, "Intro");
}
/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name,
/// leaves the story alone. Its dimensions stop at the documented limits.
#[test]
fn insert_table_with_a_style_is_one_step() {
let (mut s, sid, _) = session_with_frame();
s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
for bad in [json!("Missing"), json!(7)] {
assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}");
}
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty());
for over in [json!({"rows": 501}), json!({"cols": 201}), json!({"headerRows": 101})] {
assert!(s.execute("table.insert", &over).is_err(), "{over}");
}
let r = s.execute("table.insert", &json!({"rows": 500, "cols": 200, "headerRows": 100, "tableStyle": "Grid"})).unwrap();
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
assert_eq!(t["style"], "Grid");
let n = |k: &str| t[k].as_array().unwrap().len() as u64;
use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS};
assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo");
}
#[test]
fn convert_text_to_table_and_back() {
let (mut s, sid, _) = session_with_frame();
@@ -444,6 +534,74 @@ fn convert_text_to_table_and_back() {
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter");
}
/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or
/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style.
#[test]
fn convert_text_to_table_with_indesigns_separators() {
let (mut s, sid, _) = session_with_frame();
let convert = |s: &mut Session, text: &str, p: Value| -> Result<Value> {
s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
s.execute("table.convertFromText", &p)
};
let cells = |s: &mut Session, r: &Value| -> (usize, Vec<String>) {
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect();
(t["columns"].as_array().unwrap().len(), texts)
};
// Comma-separated columns, paragraphs for rows.
let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap();
assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()]));
check(&s);
// The lesson's list: one paragraph per cell; with two columns, two cells a row.
let list = "Name:\nAddress:\nCity:\nState:";
let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
assert_eq!(r["rows"], 2);
assert_eq!(cells(&mut s, &r).0, 2);
let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap();
assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs");
// Any characters separate: here " | " between columns and ";" between rows.
let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap();
assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()]));
// A table style is applied to the new table.
s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap();
assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid");
// A short last row: its padding cell takes the text's format too.
let r = convert(&mut s, "a\nb\nc", json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
let t = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&r["table"].as_u64().unwrap()].clone();
let fmt = |c: usize| t.cell(1, c).unwrap().text.chars[0].format.clone();
assert_eq!(fmt(1), fmt(0), "the padding cell's format is the text's");
for bad in [
json!({"columnSeparator": ""}),
json!({"rowSeparator": 3}),
json!({"columns": 0}),
json!({"columns": crate::cmd::table::MAX_COLUMNS + 1}),
json!({"tableStyle": "Missing"}),
json!({"tableStyle": 123}),
] {
assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}");
}
// Text that would make too wide or too big a table is refused, and stays as it was.
use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS};
let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t");
assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be");
let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n");
assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have");
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter"));
let tall = vec!["a"; designcraft_doc::MAX_TABLE_ROWS + 1].join("\n");
assert!(convert(&mut s, &tall, json!({})).is_err(), "more rows than a table can have");
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{tall}\nAfter"), "no text is dropped");
let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t");
assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be");
check(&s);
// Back to text with a comma between columns and a tab between rows.
let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap();
s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap();
check(&s);
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter");
}
#[test]
fn caret_placement_in_cells_and_cell_formatting() {
let (mut s, sid, fid) = session_with_frame();
+1
View File
@@ -110,6 +110,7 @@ impl Session {
snap: view.snap,
unit: view.unit,
stroke_in_front: view.stroke_in_front,
loaded: self.loaded.map(|l| l.size),
};
Some(f(self, &cx))
}
+12 -1
View File
@@ -1,4 +1,5 @@
//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing.
//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing; a
//! `before` must name a page, and one that doesn't is refused without a change.
use designcraft_engine::Session;
use serde_json::json;
@@ -14,3 +15,13 @@ fn insert_after_u64_max_appends_at_the_end() {
assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end");
assert!(after[2..].iter().all(|id| !before.contains(id)));
}
#[test]
fn insert_before_u64_max_is_refused_without_overflow() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages":2})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert!(s.execute("layout.pages.insert", &json!({"before": u64::MAX, "count": 2})).is_err());
assert_eq!(s.doc().unwrap().doc.page_count(), 2, "no pages added");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
}