mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
Merge main into feat/typeshortcuts
This commit is contained in:
commit
fa8684e30b
269 files changed
+24329
-1910
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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}\""))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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", ¶ms).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}");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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)) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) = ¶_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();
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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(©, 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(©).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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}))
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"})]
|
||||
{
|
||||
|
||||
@@ -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, ¶ms)?.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() {
|
||||
|
||||
@@ -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", ¶ms).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", ¶ms).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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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(¶);
|
||||
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(())
|
||||
})
|
||||
|
||||
@@ -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};
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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()});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user