mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 14:16:49 -06:00
Merge branch 'print-color' into print-color-x1a
# Conflicts: # Cargo.lock # Cargo.toml # xtask/src/pdf_deps.rs
This commit is contained in:
@@ -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")) }
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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<_>>(),
|
||||
})
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
Reference in New Issue
Block a user