Merge branch 'print-color' into print-color-x1a

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	xtask/src/pdf_deps.rs
This commit is contained in:
aiongg
2026-10-23 08:16:07 +00:00
155 changed files with 6908 additions and 1059 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
fn check_item(it: &Item) -> Result<()> {
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
+1 -1
View File
@@ -436,7 +436,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) {
p.guides = from.guides.clone();
p.parent = from.parent;
p.margins = from.margins;
p.columns = from.columns.clone();
p.columns = p.columns_from(from);
p.local_margins = from.local_margins;
p.width = from.width;
p.height = from.height;
+135 -27
View File
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
// Items copied last (no text copied since) paste into the text as anchored objects.
if formatted
&& clip.is_none()
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
// Fresh ids, story and asset copies for every paste, as on the page.
let mut stories = std::collections::HashMap::new();
let fresh: Vec<designcraft_doc::Item> = list
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -256,28 +298,39 @@ pub fn specs() -> Vec<CommandSpec> {
st.bump_revision();
ok()
}),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
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.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
cmd!(
"edit.clear",
"Clear",
["Edit"],
Some("Delete"),
"{ids?} — delete selected items (or the selected text or ruler guides)",
has_doc,
|s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}),
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
return s.execute("guide.delete", &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.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}
),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
@@ -316,7 +369,8 @@ pub fn specs() -> Vec<CommandSpec> {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -527,6 +581,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
#[test]
fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let story = frame(&mut s, "ab");
let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let marker = copied_objects_text(&s);
s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
// What the object copy put on the system clipboard still pastes the objects.
assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
// With no insertion point too: the rectangle, not a text frame holding the marker.
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
let id = ItemId(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
// Paste without Formatting inserts the text given; empty text is no text.
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
}
/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
#[test]
fn copied_text_frames_paste_at_a_caret_as_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let target = frame(&mut s, "ab");
let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.paste", &json!({"text": text})).unwrap()
};
let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
// The same text copied in another app, and the system clipboard read back as absent.
let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
assert!(r.get("anchored").is_none(), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
// A group with a text frame inside, its own placeholder on the system clipboard.
let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
let r = copy_and_paste(&mut s, json!([group]), Value::Null);
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
+6 -5
View File
@@ -744,11 +744,11 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I
}
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::Document>, usize) {
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
let ids: Vec<designcraft_doc::ItemId> =
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
if ids.is_empty() {
return (None, 0);
return Ok((None, 0));
}
let mut out = d.clone();
let mut n = 0;
@@ -763,7 +763,8 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
let Some(png) = designcraft_render::pdf_page_png(&a.data, a.page as usize, side, &hidden, shown) else { continue };
let png = designcraft_render::pdf_page_png_checked(&a.data, a.page as usize, side, &hidden, shown)
.map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?;
let px = designcraft_render::image_size(&png);
let aid = designcraft_doc::AssetId(out.alloc());
out.assets.insert(
@@ -782,7 +783,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::
}
n += 1;
}
(Some(out), n)
Ok((Some(out), n))
}
/// Object number of the first page of a PDF (its page tree's first kid).
@@ -829,7 +830,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
let flattened = if x1a { None } else { ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)) };
// Placed PDFs with hidden layers go out as images showing just their visible layers.
let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);
let (layered, layer_count) = rasterize_layered(base);
let (layered, layer_count) = rasterize_layered(base)?;
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
let shadows = if x1a { shadow_masks(base2, &s.cache, f64::from(opts.flatten_dpi)) } else { Default::default() };
let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache, &shadows);
+8 -1
View File
@@ -629,6 +629,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
})
.transpose()?;
// Image Import Options: which page of a PDF (1-based).
if designcraft_render::is_pdf(&bytes) {
designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?;
}
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
Some(n) if designcraft_render::is_pdf(&bytes) => {
let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1);
@@ -676,7 +679,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "file.place")?;
// `"spread": null` counts as omitted, like the other placement parameters.
let spread = match p.get("spread") {
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
_ => super::spread_param(p, "file.place")?,
};
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
+564 -27
View File
@@ -1,18 +1,28 @@
//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides.
//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create
//! Guides.
//!
//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the
//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard.
//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands
//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made
//! stale.
use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
use std::collections::{HashMap, HashSet};
use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{DocState, Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
/// The farthest a guide may be from the spread origin, in points, either way.
const MAX_GUIDE_POSITION: f64 = 1_000_000.0;
/// The most guides one `guides` list may name.
const MAX_GUIDES_NAMED: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -21,34 +31,96 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide",
[],
None,
"{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}",
"{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected",
has_doc,
add
),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide),
cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide),
cmd!(
"guide.moveBy",
"Move Guides",
[],
None,
"{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step",
has_doc,
move_by
),
cmd!(
"guide.delete",
"Delete Guide",
[],
None,
"{spread?, page, index}, or {} for the selected guides; an address without page or index is an error",
has_doc,
delete
),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = super::spread_param(p, "guide.deleteAll")?;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
for pg in &mut sp.pages {
n += pg.guides.len();
pg.guides.clear();
}
sel.guides.retain(|g| g.spread != r);
Ok(json!({"deleted": n}))
})
}),
cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| {
let d = &s.doc()?.doc;
let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread}));
cmd!(
"view.lockColumnGuides",
"Lock Column Guides",
["View", "Grids & Guides"],
None,
"{on?: bool (default: toggle)} → {locked} — locked column guides can't be dragged; saved with the document (IDML ColumnGuideLocked), on in new documents",
has_doc,
lock_column_guides
),
cmd!(
query "guide.list",
"Guides",
[],
None,
"{spread?} → [{page, index, orientation, position, spread, selected}]",
has_doc,
|s, p| {
let st = s.doc()?;
let r = super::spread_param(p, "guide.list")?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?;
let picked: HashSet<GuideSel> = st.selection.guides.iter().copied().collect();
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i });
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected}));
}
}
Ok(Value::Array(out))
}
Ok(Value::Array(out))
),
cmd!(
noundo "guide.select",
"Select Guide",
[],
None,
"{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}",
has_doc,
select
),
cmd!(
noundo "guide.selectAll",
"Select All Guides",
["Edit"],
Some("Cmd+Alt+G"),
"{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}",
has_doc,
select_all
),
cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
st.selection.guides.clear();
st.bump_revision();
super::ok()
}),
cmd!(
"layout.createGuides",
@@ -62,6 +134,18 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn lock_column_guides(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.settings.column_guides_locked;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
if on == cur {
return Ok(json!({"locked": cur}));
}
s.edit(|d, _| {
d.settings.column_guides_locked = on;
Ok(json!({"locked": on}))
})
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
@@ -80,6 +164,14 @@ fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
Ok(Some(number))
}
/// `position` as a guide position: finite and within [`MAX_GUIDE_POSITION`] of the origin.
fn guide_position(position: f64, command: &str) -> Result<f64> {
if !position.is_finite() || position.abs() > MAX_GUIDE_POSITION {
return Err(bad(command, format!("a guide position must be between -{MAX_GUIDE_POSITION} and {MAX_GUIDE_POSITION}")));
}
Ok(position)
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
@@ -115,6 +207,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
vec![(o, pos, x)]
};
for (_, pos, _) in &guides {
guide_position(*pos, "guide.add")?;
}
let d = &s.doc()?.doc;
let placed: Vec<(Orientation, f64, usize)> = guides
.into_iter()
@@ -128,11 +223,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?;
let mut out = Vec::new();
let mut added = Vec::new();
for (o, pos, pi) in placed {
let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?;
let index = pg.guides.len();
pg.guides.push(Guide {
orientation: o,
position: pos,
@@ -141,8 +238,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
layer: Some(layer),
liquid: liquid && !spread_guide,
});
out.push(json!({"page": pi, "index": pg.guides.len() - 1}));
out.push(json!({"page": pi, "index": index}));
added.push(GuideSel { spread: r, page: pi, index });
}
// A new guide is selected, as in InDesign; nothing else stays selected.
*sel = Selection { guides: added, ..Default::default() };
match <[Value; 1]>::try_from(out) {
Ok([one]) => Ok(one),
Err(out) => Ok(json!({"guides": out})),
@@ -168,6 +268,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = guide_position(pos, "guide.move")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
@@ -181,18 +282,209 @@ fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.delete")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.delete")?;
let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?;
if i >= pg.guides.len() {
return Err(bad("guide.delete", "no such guide"));
// An address names one guide: a partial one (only `spread`, say) is an error, not the selection.
let targets = if ["spread", "page", "index"].iter().all(|k| p.get(k).is_none()) {
s.doc()?.selection.guides.clone()
} else {
let (spread, page, index) = guide_ref(p, "guide.delete")?;
vec![GuideSel { spread, page, index }]
};
if targets.is_empty() {
return Err(bad("guide.delete", "no guide selected"));
}
s.edit(|d, sel| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.delete")?;
}
pg.guides.remove(i);
Ok(Value::Null)
let deleted = remove_guides(d, sel, &targets, "guide.delete")?;
Ok(json!({"deleted": deleted}))
})
}
/// Removes `targets` (existing guides; repeats count once) and renumbers the guide selection to
/// match. Returns how many guides it removed.
fn remove_guides(d: &mut Document, sel: &mut Selection, targets: &[GuideSel], command: &str) -> Result<usize> {
let gone: HashSet<GuideSel> = targets.iter().copied().collect();
// Per page, the removed indices in ascending order.
let mut by_page: HashMap<(SpreadRef, usize), Vec<usize>> = HashMap::new();
for g in &gone {
by_page.entry((g.spread, g.page)).or_default().push(g.index);
}
for ((spread, page), indices) in &mut by_page {
indices.sort_unstable();
let pg = d.spread_mut(*spread).and_then(|sp| sp.pages.get_mut(*page)).ok_or_else(|| bad(command, "no such page"))?;
for &i in indices.iter().rev() {
if i >= pg.guides.len() {
return Err(bad(command, "no such guide"));
}
pg.guides.remove(i);
}
}
sel.guides = sel
.guides
.iter()
.filter(|g| !gone.contains(g))
.map(|g| {
let below = by_page.get(&(g.spread, g.page)).map_or(0, |indices| indices.partition_point(|&i| i < g.index));
GuideSel { index: g.index.saturating_sub(below), ..*g }
})
.collect();
Ok(gone.len())
}
/// The guides a `guides: [{spread?, page, index}]` list names, if the params have one.
fn guides_param(p: &Value, command: &str) -> Result<Option<Vec<GuideSel>>> {
let Some(list) = p.get("guides") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(command, "`guides` must be a list of {spread?, page, index}"))?;
if list.len() > MAX_GUIDES_NAMED {
return Err(bad(command, format!("`guides` may name at most {MAX_GUIDES_NAMED} guides")));
}
list.iter()
.map(|g| {
let (spread, page, index) = guide_ref(g, command)?;
Ok(GuideSel { spread, page, index })
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// The guide `g` addresses, if there is one.
fn guide_of(d: &Document, g: GuideSel) -> Option<&Guide> {
d.spread(g.spread)?.pages.get(g.page)?.guides.get(g.index)
}
/// A guide that can be selected: it exists, and neither it nor its layer is locked or hidden.
pub(crate) fn selectable(d: &Document, g: GuideSel) -> bool {
guide_of(d, g).is_some_and(|guide| guide.editable_in(d))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let named = match guides_param(p, "guide.select")? {
Some(list) => list,
None => {
let (spread, page, index) = guide_ref(p, "guide.select")?;
vec![GuideSel { spread, page, index }]
}
};
let (add, toggle) = (bool_or(p, "add", false), bool_or(p, "toggle", false));
let st = s.doc_mut()?;
for g in &named {
check_editable(&st.doc, g.spread, g.page, g.index, "guide.select")?;
}
let mut guides = if add || toggle { st.selection.guides.clone() } else { Vec::new() };
let mut have: HashSet<GuideSel> = guides.iter().copied().collect();
let mut seen = HashSet::new();
let mut dropped = HashSet::new();
for g in named.into_iter().filter(|g| seen.insert(*g)) {
if !have.insert(g) {
if toggle {
dropped.insert(g);
}
} else {
guides.push(g);
}
}
guides.retain(|g| !dropped.contains(g));
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::spread_param(p, "guide.selectAll")?;
let st = s.doc_mut()?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.selectAll", "no such spread"))?;
let guides: Vec<GuideSel> = sp
.pages
.iter()
.enumerate()
.flat_map(|(page, pg)| (0..pg.guides.len()).map(move |index| GuideSel { spread: r, page, index }))
.filter(|g| selectable(&st.doc, *g))
.collect();
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let dx = finite_number(p, "dx", "guide.moveBy")?.unwrap_or(0.0);
let dy = finite_number(p, "dy", "guide.moveBy")?.unwrap_or(0.0);
let mut targets = match guides_param(p, "guide.moveBy")? {
Some(list) => list,
None => s.doc()?.selection.guides.clone(),
};
let mut seen = HashSet::new();
targets.retain(|g| seen.insert(*g));
if targets.is_empty() {
return Err(bad("guide.moveBy", "no guide selected"));
}
s.edit(|d, _| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.moveBy")?;
}
for g in &targets {
let guide = d
.spread_mut(g.spread)
.and_then(|sp| sp.pages.get_mut(g.page))
.and_then(|pg| pg.guides.get_mut(g.index))
.ok_or_else(|| bad("guide.moveBy", "no such guide"))?;
let to = guide.position
+ match guide.orientation {
Orientation::Vertical => dx,
Orientation::Horizontal => dy,
};
guide.position = guide_position(to, "guide.moveBy").map_err(|_| bad("guide.moveBy", "the guide would move out of range"))?;
}
Ok(json!({"moved": targets.len()}))
})
}
/// After a command: guides stay selected only while nothing else is (objects, text, cells, a
/// page), and only those that are still there and can be selected. A command that changed
/// which guides a page holds without keeping the guide selection up to date clears it, since
/// its (page, index) addresses may name other guides now. `before` is the document and
/// selection before the command, when it ran on the same document.
pub(crate) fn keep_selection_valid(st: &mut DocState, before: Option<(&Document, &Selection)>, id: &str) {
let sel = &mut st.selection;
if sel.guides.is_empty() {
return;
}
if !sel.items.is_empty() || sel.text.is_some() || sel.cells.is_some() || sel.page.is_some() {
sel.guides.clear();
return;
}
let kept_up = id.starts_with("guide.") || matches!(id, "edit.undo" | "edit.redo") || before.is_some_and(|(_, old)| old.guides != sel.guides);
if !kept_up
&& let Some((old, _)) = before
&& !std::ptr::eq(old, st.doc.as_ref())
&& !same_guide_addresses(old, &st.doc)
{
sel.guides.clear();
return;
}
let mut seen = HashSet::new();
sel.guides.retain(|g| selectable(&st.doc, *g) && seen.insert(*g));
}
/// Every (spread, page, index) of `old` still names a guide of the same orientation on the same
/// page in `new`: guides were only added at the end of their pages, or moved.
fn same_guide_addresses(old: &Document, new: &Document) -> bool {
let same = |a: &[std::sync::Arc<designcraft_doc::Spread>], b: &[std::sync::Arc<designcraft_doc::Spread>]| {
a.len() == b.len()
&& a.iter().zip(b).all(|(a, b)| {
a.pages.len() == b.pages.len()
&& a.pages.iter().zip(&b.pages).all(|(a, b)| {
a.id == b.id
&& a.guides.len() <= b.guides.len()
&& a.guides.iter().zip(&b.guides).all(|(a, b)| a.orientation == b.orientation)
})
})
};
same(&old.spreads, &new.spreads) && same(&old.parents, &new.parents)
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
@@ -273,7 +565,11 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
if remove {
// The cleared pages' guide addresses name the new guides now.
sel.guides.retain(|g| g.spread != r || only.is_some_and(|o| o != g.page));
}
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0usize;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
@@ -315,6 +611,39 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
#[test]
fn lock_column_guides_toggles_sets_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let locked = |s: &Session| s.doc().unwrap().doc.settings.column_guides_locked;
let undo_steps = |s: &Session| s.doc().unwrap().history.undo.len();
assert!(locked(&s), "locked in new documents");
let before = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
assert!(!locked(&s));
assert_eq!(undo_steps(&s), before + 1, "one undo step per toggle");
s.execute("edit.undo", &json!({})).unwrap();
assert!(locked(&s), "undo restores the lock");
s.execute("edit.redo", &json!({})).unwrap();
assert!(!locked(&s), "redo unlocks again");
// Setting the current value changes nothing and adds no undo step.
let steps = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap()["locked"], false);
assert_eq!(undo_steps(&s), steps);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap()["locked"], true);
assert!(locked(&s));
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
// Saved with the document; files without the setting read as locked.
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
assert!(json.contains("\"columnGuidesLocked\":false"), "{json}");
let back: Document = serde_json::from_str(&json).unwrap();
assert!(!back.settings.column_guides_locked);
let old = json.replace("\"columnGuidesLocked\":false,", "").replace(",\"columnGuidesLocked\":false", "");
assert!(!old.contains("columnGuidesLocked"));
let back: Document = serde_json::from_str(&old).unwrap();
assert!(back.settings.column_guides_locked);
}
#[test]
fn guides_add_move_delete_and_grid() {
let mut s = Session::new();
@@ -390,6 +719,214 @@ mod tests {
}
}
#[cfg(test)]
mod selection_tests {
use serde_json::{Value, json};
use crate::Session;
/// A page with vertical guides at 100, 200, 300 and a horizontal one at 400.
fn guides() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for x in [100.0, 200.0, 300.0] {
s.execute("guide.add", &json!({"orientation": "vertical", "position": x})).unwrap();
}
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 400.0})).unwrap();
s
}
fn selected(s: &Session) -> Vec<usize> {
s.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(s: &mut Session) -> Vec<f64> {
let l = s.execute("guide.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect()
}
#[test]
fn selecting_guides_clears_objects_and_the_other_way_round() {
let mut s = guides();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 1})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
assert_eq!(selected(&s), [1]);
// Shift-click adds, Shift-click again removes.
s.execute("guide.select", &json!({"page": 0, "index": 3, "toggle": true})).unwrap();
assert_eq!(selected(&s), [1, 3]);
s.execute("guide.select", &json!({"page": 0, "index": 1, "toggle": true})).unwrap();
assert_eq!(selected(&s), [3]);
let r = s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}], "add": true})).unwrap();
assert_eq!(r["selected"], 3);
let l = s.execute("guide.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().iter().map(|g| g["selected"] == true).collect::<Vec<_>>(), [true, false, true, true]);
// Selecting an object (Shift-click too) deselects the guides.
s.execute("selection.toggle", &json!({"id": id})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deselect", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// No such guide.
assert!(s.execute("guide.select", &json!({"page": 0, "index": 9})).is_err());
assert!(s.execute("guide.select", &json!({"page": 3, "index": 0})).is_err());
assert!(s.execute("guide.select", &json!({"guides": "all"})).is_err());
}
#[test]
fn select_all_guides_skips_locked_ones() {
let mut s = guides();
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": layer})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 500.0})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(s.execute("guide.selectAll", &json!({})).unwrap()["selected"], 4);
assert_eq!(selected(&s), [0, 1, 2, 3]);
assert!(s.execute("guide.select", &json!({"page": 0, "index": 4})).is_err());
// Locking the layer of a selected guide deselects it.
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(selected(&s), [0, 1, 2, 3]);
let spec = crate::cmd::find_command("guide.selectAll").unwrap();
assert_eq!((spec.menu, spec.shortcut), (&["Edit"][..], Some("Cmd+Alt+G")));
}
#[test]
fn selected_guides_move_and_delete_as_one_undo_step() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Vertical guides move by dx, horizontal ones by dy.
s.execute("guide.moveBy", &json!({"dx": 10.0, "dy": -5.0})).unwrap();
assert_eq!(positions(&mut s), [110.0, 200.0, 310.0, 395.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
// Delete (edit.clear, as the Delete key) takes all three; the guide left is the one at 200.
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 3);
assert_eq!(positions(&mut s), [200.0]);
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [200.0]);
assert!(s.execute("guide.moveBy", &json!({"dx": 1.0})).is_err(), "nothing selected");
// A guide stays within 1000000 pt of the origin.
assert!(s.execute("guide.moveBy", &json!({"dx": f64::MAX, "guides": [{"page": 0, "index": 0}]})).is_err());
assert!(s.execute("guide.moveBy", &json!({"dx": 1e6, "guides": [{"page": 0, "index": 0}]})).is_err());
assert_eq!(positions(&mut s), [200.0]);
s.execute("guide.moveBy", &json!({"dx": -1e6 - 200.0, "guides": [{"page": 0, "index": 0}]})).unwrap();
assert_eq!(positions(&mut s), [-1e6]);
assert!(s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 1e6 + 1.0})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": -2e6})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [0.0, 1e300]})).is_err());
assert_eq!(positions(&mut s), [-1e6]);
}
#[test]
fn the_guide_selection_follows_deletes_and_drops_stale_guides() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Deleting a guide before them renumbers them.
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
assert_eq!(selected(&s), [1, 2]);
assert_eq!(positions(&mut s), [200.0, 300.0, 400.0]);
// Deleting a selected one deselects it.
s.execute("guide.delete", &json!({"page": 0, "index": 1})).unwrap();
assert_eq!(selected(&s), [1]);
// Undo puts back the selection that went with the document.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [1, 2]);
// A new guide is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 50.0})).unwrap();
assert_eq!(selected(&s), [3]);
// Undoing the add to before the first delete: those addresses held other guides then.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [2, 3]);
// Create Guides replacing the page's guides: their addresses name new guides, so the
// selection goes.
s.execute("layout.createGuides", &json!({"columns": 2, "removeExisting": true})).unwrap();
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": 0})).unwrap();
assert_eq!(selected(&s), [0], "guides only added: the addresses still hold");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deleteAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// Deleting the page goes with it.
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap();
s.execute("guide.selectAll", &json!({"spread": 1})).unwrap();
assert_eq!(selected(&s), [0]);
s.execute("layout.pages.delete", &json!({"pages": [1]})).unwrap();
assert!(selected(&s).is_empty());
let _: Value = s.execute("guide.list", &json!({})).unwrap();
}
}
#[cfg(test)]
mod new_guide_tests {
use designcraft_doc::GuideSel;
use serde_json::json;
use crate::Session;
fn selected(s: &Session) -> Vec<GuideSel> {
s.doc().unwrap().selection.guides.clone()
}
#[test]
fn a_new_guide_is_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100.0})).unwrap();
let spread = s.doc().unwrap().selection.guides[0].spread;
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
assert!(s.doc().unwrap().selection.items.is_empty(), "objects are deselected");
// Both ways (Cmd-drag from the ruler corner): the two new guides.
s.execute("guide.add", &json!({"orientation": "both", "point": [50.0, 60.0], "spreadGuide": true})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 1 }, GuideSel { spread, page: 0, index: 2 }]);
// Undo gives back the selection from before.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
}
#[test]
fn delete_takes_a_whole_address_or_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
for spread in [0, 1] {
s.execute("guide.add", &json!({"spread": spread, "orientation": "vertical", "position": 100.0})).unwrap();
}
s.execute("guide.select", &json!({"spread": 1, "page": 0, "index": 0})).unwrap();
// A spread without page and index is not "the selection".
assert!(s.execute("guide.delete", &json!({"spread": 0})).is_err());
assert!(s.execute("guide.delete", &json!({"spread": 1, "page": 0})).is_err());
assert_eq!(selected(&s).len(), 1);
// A selection naming a guide twice, apart, deletes it once.
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 200.0})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 300.0})).unwrap();
let spread = selected(&s)[0].spread;
let at = |index| GuideSel { spread, page: 0, index };
s.doc_mut().unwrap().selection.guides = vec![at(0), at(2), at(0)];
assert_eq!(s.execute("guide.delete", &json!({})).unwrap()["deleted"], 2);
let left = s.execute("guide.list", &json!({"spread": 1})).unwrap();
assert_eq!(left.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect::<Vec<_>>(), [200.0]);
assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap().as_array().unwrap().len(), 1);
}
}
#[cfg(test)]
mod layer_tests {
use serde_json::json;
+3 -1
View File
@@ -85,7 +85,9 @@ fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
let abs = d.first_page_of_spread(si) + pi;
json!({"index": abs, "name": d.page_name(abs), "bounds": [p.x, 0.0, p.x + p.width, p.height], "side": p.side,
"parent": p.parent.and_then(|pid| d.parents.iter().find(|x| x.id == pid)).and_then(|x| x.parent.as_ref()).map(|i| i.label()),
"margins": p.margins, "columns": p.columns.count})
"margins": p.margins, "columns": p.columns.count, "gutter": p.columns.gutter,
"customColumns": p.columns.valid_positions(p.margin_rect().width()).is_some(),
"columnRects": p.column_rects().iter().map(|r| [r.x0, r.y0, r.x1, r.y1]).collect::<Vec<_>>()})
}).collect::<Vec<_>>(),
"items": sp.items.iter().map(|i| item_json(i)).collect::<Vec<_>>(),
})
+451 -9
View File
@@ -88,6 +88,7 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 {
/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page.
fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
let width_before = pg.margin_rect().width();
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
@@ -97,14 +98,62 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
}
_ => {}
}
scale_columns(pg, width_before);
// A new count or gutter re-divides the columns evenly: dragged column positions are what
// the new value replaces. The dialog always sends both, so an unchanged value keeps them.
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216);
if c != pg.columns.count {
pg.columns.count = c;
pg.columns.positions = None;
}
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) {
let g = g.max(0.0);
if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE {
pg.columns.gutter = g;
pg.columns.positions = None;
}
}
}
/// Dragged column positions are measured across the margin box. When the box was
/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set
/// that doesn't scale into a valid one goes back to the even split rather than stay hidden.
fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) {
let width = pg.margin_rect().width();
if (width - width_before).abs() <= GUTTER_TOLERANCE {
return;
}
let Some(positions) = pg.columns.positions.as_mut() else { return };
let factor = width / width_before;
if factor.is_finite() && factor > 0.0 {
positions.iter_mut().for_each(|p| *p *= factor);
// The ends sit exactly on the margins whatever the rounding.
if let Some(first) = positions.first_mut() {
*first = 0.0;
}
if let Some(last) = positions.last_mut() {
*last = width;
}
}
if pg.columns.valid_positions(width).is_none() {
pg.columns.positions = None;
}
}
/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box.
fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) {
let before = pg.margin_rect().width();
pg.width = width;
scale_columns(pg, before);
}
/// How far a gutter may differ from the page's and still count as unchanged, in points. The
/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt
/// off), so an OK that leaves the field alone sends back a slightly different number.
const GUTTER_TOLERANCE: f64 = 0.01;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -197,8 +246,9 @@ pub fn specs() -> Vec<CommandSpec> {
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
let pg = &mut nsp.pages[npi];
let dx = pg.x - src.x;
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
(src.width, src.height, src.margins, src.columns.clone(), src.liquid);
// A copy on the other side of the spine has its dragged columns mirrored.
let columns = pg.columns_from(src);
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid);
pg.guides = src.guides.clone();
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
@@ -252,8 +302,20 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").unwrap_or("Parent").to_string();
s.edit(|d, _| {
let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix());
let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
let id = d.add_parent(&prefix, &name, m.0, m.1, m.2);
let src = d.page(0).cloned();
let (columns, margins) =
src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
// Dragged column guides carry over to the parent pages they still fit, mirrored
// on the parent page across the spine from the first page.
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
for pg in &mut std::sync::Arc::make_mut(sp).pages {
let columns = pg.columns_from(src);
if columns.valid_positions(pg.margin_rect().width()).is_some() {
pg.columns.positions = columns.positions;
}
}
}
Ok(json!({"id": id.0, "prefix": prefix}))
})
}),
@@ -368,6 +430,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"layout.moveColumnGuide",
"Move Column Guide",
[],
None,
"{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked",
column_guides_unlocked,
move_column_guide
),
cmd!(
"layout.documentSetup",
"Document Setup…",
@@ -477,7 +548,7 @@ pub fn specs() -> Vec<CommandSpec> {
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
let sp = std::sync::Arc::make_mut(sp);
for pg in &mut sp.pages {
pg.width = d.settings.page_width;
set_page_width(pg, d.settings.page_width);
pg.height = d.settings.page_height;
}
sp.relayout();
@@ -808,6 +879,52 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column
/// Guides is off.
fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
match s.active() {
Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()),
_ => Ok(()),
}
}
/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`.
fn move_column_guide(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layout.moveColumnGuide";
let index = |key: &str| -> Result<usize> {
let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?;
v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer")))
};
let (page, gutter) = (index("page")?, index("gutter")?);
let position =
p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?;
let r = match p.get("parent") {
None | Some(Value::Null) => super::spread_param(p, CMD)?,
Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?),
};
s.edit(|d, _| {
if d.settings.column_guides_locked {
return Err(bad(CMD, "column guides are locked"));
}
let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?;
let m = pg.margin_rect();
let count = pg.columns.count;
if gutter.checked_add(1).is_none_or(|g| g >= count as usize) {
return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1))));
}
let positions =
pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?;
let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v);
pg.columns.positions = Some(positions.clone());
match r {
designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true,
designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(),
}
Ok(json!({"positions": positions, "left": left}))
})
}
/// A parent spread named by prefix, label (`A-Parent`) or index.
fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option<usize> {
match v {
@@ -957,7 +1074,7 @@ pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usiz
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 {
pg.width = w.max(1.0);
set_page_width(pg, w.max(1.0));
}
if let Some(h) = h {
pg.height = h.max(1.0);
@@ -1650,6 +1767,191 @@ mod adjust_tests {
// An unknown parent is an error, not a silent no-op.
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err());
}
/// Margins and Columns re-divides the columns: a new count or gutter replaces column guides
/// that were dragged (an imported Custom columns page), rather than being ignored by them.
#[test]
fn a_new_column_count_replaces_dragged_column_positions() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
// There is no command for dragging a column guide yet, so set what an import would leave.
let drag = |s: &mut Session| {
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
Ok(())
})
.unwrap();
};
drag(&mut s);
let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert_eq!(dragged.len(), 2);
assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}");
// A new count: evenly divided again, and the stale positions are gone.
s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None));
assert_eq!(pg.column_rects().len(), 3);
// So does a new gutter on its own.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!(pg.columns.positions, None);
assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6);
// Margins that keep the margin box's width leave them alone; a new width scales them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap();
assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0]));
s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap();
assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0);
}
/// The params the Margins and Columns dialog sends on OK: every margin, the count and the
/// gutter, whether or not the user changed them.
fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value {
json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin},
"columns": columns, "gutter": gutter, "adjustLayout": false})
}
const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0];
fn dragged() -> designcraft_doc::Columns {
designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) }
}
/// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide.
#[track_caller]
fn assert_scaled(positions: Option<Vec<f64>>, width: f64) {
let want: Vec<f64> = DRAGGED.iter().map(|p| p * width / 540.0).collect();
let got = positions.unwrap_or_default();
assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}");
}
/// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs.
#[test]
fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
// The same count and gutter (the gutter as an inch field rounds it) keep them in use.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}");
// Top and bottom margins leave the margin box's width alone, and the columns with it.
let mut taller = dialog_params(36.0, 2, 12.0);
taller["margins"]["top"] = json!(72.0);
taller["margins"]["bottom"] = json!(72.0);
s.execute("layout.marginsAndColumns", &taller).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
// A new width scales them with the margin box, and the old width scales them back.
s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 468.0);
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}");
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 540.0);
// A new count resets them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions), (3, None));
// So does a new gutter.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None));
}
/// A page of another width (Page Size, Document Setup) scales its dragged columns.
#[test]
fn a_new_page_width_scales_dragged_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
// A dragged page's columns are its own (`local_margins`), as the drag will make them.
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = dragged();
pg.local_margins = true;
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
drag(&mut s);
s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them");
s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap();
assert_scaled(positions(&s), 540.0);
drag(&mut s);
s.execute("layout.documentSetup", &json!({"width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
}
/// Parent pages follow the same rule, and the document pages that follow them see it.
#[test]
fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged();
d.sync_parent_margins();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| {
let d = &s.doc().unwrap().doc;
(d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone())
};
let on_parent = |m: f64, c: u32, g: f64| {
let mut p = dialog_params(m, c, g);
p["parent"] = json!(0);
p
};
drag(&mut s);
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap();
let (parent, follower) = positions(&s);
assert_scaled(parent, 516.0);
assert_scaled(follower, 516.0);
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap();
assert_eq!(positions(&s), (None, None));
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap();
assert_eq!(positions(&s), (None, None));
}
}
#[cfg(test)]
@@ -1948,6 +2250,146 @@ mod parent_tests {
assert_eq!(r, json!({"on": false, "parent": null}));
assert!(!s.doc().unwrap().editing_parents);
}
/// A new parent takes the first page's columns, dragged column guides included: the right
/// parent page as the first (right) page has them, the left one mirrored across the spine.
/// `document.inspect` reports where those columns are.
#[test]
fn a_new_parent_keeps_dragged_columns() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
// There is no command for dragging a column guide yet, so set what an import would leave.
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
Ok(())
})
.unwrap();
let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap();
assert_eq!(parent.pages.len(), 2);
let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap();
let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right));
assert_eq!(right.columns, dragged);
assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![100.0, 410.0]);
assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0]));
assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![410.0, 100.0]);
let inspect = s.execute("document.inspect", &json!({})).unwrap();
let page = &inspect["spreads"][0]["pages"][0];
assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}");
assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}");
}
fn columns_of(s: &crate::Session, abs: usize) -> (Option<Vec<f64>>, bool) {
let pg = s.doc().unwrap().doc.page(abs).unwrap().clone();
(pg.columns.positions, pg.local_margins)
}
/// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked.
fn three_columns(pages: u64) -> crate::Session {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": pages})).unwrap();
s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap();
s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap();
s
}
/// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring
/// gutter and the margin, makes a following page's columns its own, and undoes in one step.
#[test]
fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() {
let mut s = three_columns(1);
assert_eq!(columns_of(&s, 0), (None, false), "the page follows A");
let mv =
|s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x}));
let r = mv(&mut s, 0, 236.0).unwrap();
assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0}));
assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true));
let inspect = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0]));
// Past the next gutter: it stops there, and the middle column has no width.
assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0));
// Past the right margin: the last gutter stops 12 pt (its width) inside it.
assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0]));
// Past the left margin.
assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0]));
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag");
}
/// On a parent page the drag changes the parent, and the document pages that follow it get
/// the new columns; a page with columns of its own keeps them.
#[test]
fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() {
let mut s = three_columns(3);
// Page 1, the left page of spread 1, gets columns of its own.
let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0;
s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap();
let own = columns_of(&s, 1);
assert!(own.1 && own.0.is_some());
let d = &s.doc().unwrap().doc;
// A's right page: the one page 0 (a right page) and page 2 follow.
let x0 = d.parents[0].pages[1].margin_rect().x0;
s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap();
let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0];
assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone()));
assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false));
assert_eq!(columns_of(&s, 2), (Some(moved), false));
assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own");
// The same parent page by spread reference.
let spread = json!({"kind": "parent", "index": 0});
s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap();
assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0);
}
/// A drag's preview in one document is put back there when another document became active.
#[test]
fn a_column_guide_preview_is_cancelled_in_its_own_document() {
let mut s = three_columns(1);
let uid = s.doc().unwrap().uid;
s.begin_interaction("Move Column Guide").unwrap();
s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap();
s.execute("file.new", &json!({})).unwrap();
s.cancel_interaction_of(uid);
s.set_active(0);
assert!(s.doc().unwrap().interaction.is_none());
assert_eq!(columns_of(&s, 0), (None, false));
}
#[test]
fn move_column_guide_refuses_while_locked_and_hostile_params() {
let mut s = three_columns(1);
s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap();
assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err());
assert_eq!(columns_of(&s, 0), (None, false));
s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap();
for p in [
json!({"page": 0, "gutter": 0}),
json!({"page": 0, "gutter": 0, "position": f64::NAN}),
json!({"page": 0, "gutter": 0, "position": "200"}),
json!({"page": 0, "gutter": 2, "position": 200}),
json!({"page": 0, "gutter": u64::MAX, "position": 200}),
json!({"page": 0, "gutter": -1, "position": 200}),
json!({"page": 0, "gutter": 0.5, "position": 200}),
json!({"page": 9, "gutter": 0, "position": 200}),
json!({"page": u64::MAX, "gutter": 0, "position": 200}),
json!({"gutter": 0, "position": 200}),
json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}),
json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}),
json!({"page": 0, "gutter": 0, "position": f64::MAX}),
] {
let r = s.execute("layout.moveColumnGuide", &p);
// A huge but finite position is clamped to the margin; everything else is refused.
assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}");
}
}
}
#[cfg(test)]
+3
View File
@@ -398,6 +398,9 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
if designcraft_render::is_pdf(&b) {
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
}
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?;
Ok((b, px))
}
+2 -1
View File
@@ -17,10 +17,11 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
mod guides;
pub(crate) mod guides;
mod index;
mod inspect;
mod interactive;
+2 -1
View File
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
+38 -1
View File
@@ -277,7 +277,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
@@ -511,6 +511,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Autoflow follows dragged column guides: one frame per dragged column, on the first page and
/// on every page it adds.
#[test]
fn autoflow_sets_frames_on_dragged_columns() {
let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
// The margin box is 504 pt wide; the gutter is dragged left. There is no command for
// dragging a column guide yet, so set what an import would leave, on the parent.
let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) };
s.edit(|d, _| {
for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages {
pg.columns = dragged.clone();
}
d.sync_parent_margins();
Ok(())
})
.unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() >= 3, "{r}");
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len());
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
let m = d.page(page).unwrap().margin_rect();
let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2];
assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_keeps_the_frame_the_user_drew() {
let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id()));
+11
View File
@@ -571,6 +571,7 @@ impl Session {
Ok(r) => r,
Err(e) => {
// What it did before it failed can be undone too.
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
return Err(e);
}
@@ -613,6 +614,7 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
@@ -621,6 +623,15 @@ impl Session {
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
/// recorded: after the passes that run with the command (smart reflow, live captions, autosize).
fn keep_guide_selection_valid(&mut self, before: Option<&(u64, Arc<Document>, Selection)>, id: &str) {
if let Some(st) = self.active_mut() {
let old = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, sel)| (d.as_ref(), sel));
cmd::guides::keep_selection_valid(st, old, id);
}
}
/// Record undo if the document changed (and we're not previewing an interaction), with the
/// selection from before the command. A command run by an undoable one leaves the step to it:
/// one command is one undo step.
+2 -1
View File
@@ -294,8 +294,9 @@ fn cmd_move_does_not_snap() {
fn cmd_resize_still_snaps_to_a_guide() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
// The new guide is selected: the frame created after it is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
+14
View File
@@ -331,4 +331,18 @@ impl Session {
}
}
}
/// Cancel the interaction of the document with `uid`, active or not: a gesture that began in
/// one document and lost it to another tab ends there, leaving the document as it began.
pub fn cancel_interaction_of(&mut self, uid: u64) {
if self.active().is_some_and(|st| st.uid == uid) {
self.cancel_interaction();
} else if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
&& let Some(it) = st.interaction.take()
{
st.doc = it.doc;
st.selection = it.selection;
st.revision = it.revision;
}
}
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
+23
View File
@@ -0,0 +1,23 @@
#[cfg(not(target_arch = "wasm32"))]
mod book_safety;
#[cfg(target_arch = "wasm32")]
mod browser_print;
mod character_style_from_selection;
mod close_retention;
#[cfg(not(target_arch = "wasm32"))]
mod collection_state_atomicity;
mod combined_close_glyph;
mod distribute_locks;
mod export_page_input;
mod insert_pages_bounds;
mod param_keys;
mod pdf_backend;
mod preflight_resolution;
mod primary_frame_columns;
#[cfg(not(target_arch = "wasm32"))]
mod save_state_atomicity;
mod stroke_tint_range;
mod text_decorations;
mod text_insert_memory;
mod text_thread_regressions;
mod thread_color_safety;
+349
View File
@@ -0,0 +1,349 @@
//! Synthetic placement/export regressions for PDF backend updates.
use serde_json::json;
use designcraft_engine::Session;
use designcraft_engine::cmd::{base64_decode, base64_encode};
fn pdf(objects: &[String]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0];
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", offsets.len()).as_bytes());
for offset in offsets.iter().skip(1) {
bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).as_bytes());
bytes
}
fn layered_pdf(second_name: &str) -> Vec<u8> {
layered_pdf_with_box(second_name, "")
}
fn layered_pdf_with_box(second_name: &str, page_box: &str) -> Vec<u8> {
let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC /OC /b BDC 1 0 0 rg 55 10 35 80 re f EMC";
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] {page_box} /Resources << /Properties << /a 5 0 R /b 6 0 R >> >> /Contents 4 0 R >>"
),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /OCG /Name (Blue) >>".into(),
format!("<< /Type /OCG /Name ({second_name}) >>"),
])
}
fn rendered(bytes: &[u8]) -> designcraft_render::Rendered {
let pm = designcraft_render::render_pdf_page(bytes, 0, 100).expect("render synthetic PDF");
designcraft_render::Rendered {
width: pm.width().into(),
height: pm.height().into(),
pixels: pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(),
}
}
// Optional local evidence output; no binary fixtures are checked in.
fn evidence(name: &str, bytes: &[u8]) {
if let Some(dir) = std::env::var_os("CRAFT_PDF_EVIDENCE_DIR") {
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap();
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn placed_pdf_preserves_transparent_background_and_layer_choices() {
let bytes = layered_pdf("Red");
let img = rendered(&bytes);
assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
assert_eq!(img.pixel(75, 50), [255, 0, 0, 255]);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap();
let img = rendered(&hidden);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
evidence("layers-hidden.png", &img.to_png());
evidence("layers-source.pdf", &bytes);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn duplicate_layer_names_keep_name_based_hiding() {
let bytes = layered_pdf("Blue");
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Blue".into()]).unwrap();
let img = rendered(&hidden);
assert_eq!(img.pixel(25, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
// A second render of the original must not inherit the hidden configuration.
assert_eq!(rendered(&bytes).pixel(25, 50), [0, 0, 255, 255]);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn crop_box_and_rotation_keep_placed_dimensions() {
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 100] /CropBox [10 20 110 80] /Rotate 90 >>".into(),
]);
assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(1));
assert_eq!(designcraft_render::pdf_page_size(&bytes, 0), Some((60.0, 100.0)));
let img = rendered(&bytes);
assert_eq!((img.width, img.height), (60, 100));
assert_eq!(img.pixel(20, 20), [0, 0, 0, 0]);
}
fn exported(hidden: bool) -> Vec<u8> {
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
session.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();
let placed = session
.execute(
"file.place",
&json!({
"base64": base64_encode(&layered_pdf("Red")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100
}),
)
.unwrap();
if hidden {
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
}
let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap();
let bytes = base64_decode(report["base64"].as_str().unwrap());
if hidden {
assert!(report["warnings"].to_string().contains("hidden layers"));
}
bytes
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_layer_export_composites_over_underlying_artwork() {
let bytes = exported(true);
let img = rendered(&bytes);
let right = img.pixel(75, 50);
assert!(right[..3].iter().all(|c| *c < 45), "hidden area must show the black backdrop, not opaque white: {right:?}");
let left = img.pixel(25, 50);
assert!(left[2] > 180 && left[0] < 80, "visible blue layer: {left:?}");
evidence("hidden-export.pdf", &bytes);
evidence("hidden-export.png", &img.to_png());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn ordinary_placed_pages_still_embed_as_vectors() {
use hayro_syntax::object::Name;
let bytes = exported(false);
let parsed = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let subtypes: Vec<_> = parsed
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter_map(|s| s.dict().get::<Name>("Subtype").map(|n| n.as_str().to_owned()))
.collect();
assert!(subtypes.iter().any(|s| s == "Form"), "placed PDF form: {subtypes:?}");
assert!(!subtypes.iter().any(|s| s == "Image"), "no rasterization: {subtypes:?}");
let img = rendered(&bytes);
let right = img.pixel(75, 50);
assert!(right[0] > 180 && right[1] < 80, "visible red vector layer: {right:?}");
evidence("vector-export.pdf", &bytes);
evidence("vector-export.png", &img.to_png());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn oversized_image_does_not_silently_disappear_from_layered_export() {
// Just above the backend's 2^28-pixel cap. The stream is one byte; no large
// fixture or large image allocation is needed to exercise the refusal.
let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC q 100 0 0 100 0 0 cm /Im Do Q";
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Properties << /a 6 0 R >> /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /XObject /Subtype /Image /Width 32769 /Height 8192 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nX\nendstream".into(),
"<< /Type /OCG /Name (Blue) >>".into(),
]);
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed = session
.execute(
"file.place",
&json!({
"base64": base64_encode(&bytes), "name": "oversized-scan.pdf", "x": 0, "y": 0, "width": 100
}),
)
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
assert!(result.is_err(), "export must report the image omitted by the backend's resource limit");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged");
let error = result.unwrap_err().to_string();
assert!(error.contains("oversized-scan.pdf") && error.contains("image"), "{error}");
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
// A stored layer choice can outlive relinking to a PDF with no layers.
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>".into(),
]);
assert!(designcraft_render::render_pdf_page(&bytes, 0, 100).is_some());
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none());
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed =
session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
assert!(result.is_err(), "failed layer hiding must not export the original visible layers");
let error = result.unwrap_err().to_string();
assert!(error.contains("missing-layers.pdf") && error.contains("layer"), "{error}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn placed_standard_font_text_renders_ink_on_a_transparent_background() {
let content = "BT /F1 18 Tf 10 40 Td (PDF text) Tj ET";
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
]);
let img = rendered(&bytes);
let ink = (40..65).flat_map(|y| (5..95).map(move |x| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[3] > 100).count();
assert!(ink > 100, "standard-font glyphs must paint visible ink: {ink}");
assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]);
assert_eq!(img.pixel(50, 80), [0, 0, 0, 0]);
assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &[], None).is_ok());
evidence("standard-font-text.png", &img.to_png());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unavailable_placed_page_reports_the_page_and_recovery_action() {
let error = designcraft_render::pdf_page_png_checked(&layered_pdf("Red"), 2, 100, &["Red".into()], None).unwrap_err();
assert!(matches!(error, designcraft_render::PdfRasterError::MissingPage { page: 3 }));
assert!(error.to_string().contains("place the replacement PDF again"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_layers_work_with_an_xref_stream_and_compressed_catalog() {
// Reuse the tiny synthetic objects, but put the catalog in an object stream
// and index everything with a binary cross-reference stream.
let classic = String::from_utf8(layered_pdf("Red")).unwrap();
let objects: Vec<&str> = (1..=6).map(|i| classic.split_once(&format!("{i} 0 obj\n")).unwrap().1.split_once("\nendobj").unwrap().0).collect();
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = [0u32; 9];
for (i, object) in objects.iter().enumerate().skip(1) {
offsets[i + 1] = u32::try_from(bytes.len()).unwrap();
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
offsets[7] = u32::try_from(bytes.len()).unwrap();
let catalog = format!("1 0 {}", objects[0]);
bytes.extend_from_slice(
format!("7 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n{catalog}\nendstream\nendobj\n", catalog.len()).as_bytes(),
);
offsets[8] = u32::try_from(bytes.len()).unwrap();
let mut entries = Vec::new();
for (i, offset) in offsets.iter().enumerate() {
let (kind, field, generation): (u8, u32, u16) = match i {
0 => (0, 0, 65535),
1 => (2, 7, 0),
_ => (1, *offset, 0),
};
entries.push(kind);
entries.extend_from_slice(&field.to_be_bytes());
entries.extend_from_slice(&generation.to_be_bytes());
}
bytes.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len()).as_bytes());
bytes.extend_from_slice(&entries);
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[8]).as_bytes());
assert_eq!(rendered(&bytes).pixel(75, 50), [255, 0, 0, 255]);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap();
let img = rendered(&hidden);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn trailing_whitespace_does_not_prevent_layer_hiding() {
let mut bytes = layered_pdf("Red");
bytes.extend_from_slice(&[b' '; 96]);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).expect("trailing whitespace is accepted by the layer rewriter");
let img = rendered(&hidden);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn failed_content_decode_aborts_layered_export_without_changing_the_document() {
for filter in ["FlateDecode", "DCTDecode"] {
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Properties << /a 5 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Filter /{filter} /Length 1 >>\nstream\nx\nendstream"),
"<< /Type /OCG /Name (Blue) >>".into(),
]);
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed = session
.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "broken-content.pdf", "x": 0, "y": 0, "width": 100}))
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string();
assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}");
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_layer_export_keeps_the_placed_trim_box() {
let bytes = layered_pdf_with_box("Red", "/TrimBox [0 0 50 100]");
let mut session = Session::new();
session.execute("file.new", &json!({"width": 50, "height": 100, "pages": 1})).unwrap();
session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
let placed = session
.execute(
"file.place",
&json!({"base64": base64_encode(&bytes), "name": "trimmed-layers.pdf", "pdfCrop": "trim", "x": 0, "y": 0, "width": 100}),
)
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap();
let exported = base64_decode(report["base64"].as_str().unwrap());
let img = rendered(&exported);
assert_eq!((img.width, img.height), (50, 100));
// Omitting the shown box squeezes the whole page into the trimmed graphic,
// moving the blue rectangle's right edge from x=45 to x=22.5.
let blue = img.pixel(35, 50);
assert!(blue[2] > 180 && blue[0] < 80, "the trim box keeps the visible layer at its placed coordinates: {blue:?}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before);
evidence("trimmed-layer-export.pdf", &exported);
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;