mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
refactor(engine): split cmd/file.rs into cmd/file/
This commit is contained in:
+14
-2153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
|
||||
/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file.
|
||||
pub fn to_bytes(d: &Document) -> Result<Vec<u8>> {
|
||||
save_bytes(d, None)
|
||||
}
|
||||
|
||||
/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture.
|
||||
fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
#[cfg(test)]
|
||||
if FAIL_TO_BYTES.with(std::cell::Cell::get) {
|
||||
return Err(EngineError::Other("could not write the document: injected failure".into()));
|
||||
}
|
||||
designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}")))
|
||||
}
|
||||
|
||||
/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so
|
||||
/// the file opens from the file manager. A path without a file name is left for the write to fail.
|
||||
pub fn with_document_extension(path: &str) -> String {
|
||||
let p = std::path::Path::new(path);
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str);
|
||||
if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) {
|
||||
return path.to_string();
|
||||
}
|
||||
format!("{path}.{}", designcraft_format::EXTENSION)
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package.
|
||||
/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their
|
||||
/// `mimetype` entry, as both formats write them.
|
||||
pub fn is_document(bytes: &[u8]) -> bool {
|
||||
designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes)
|
||||
}
|
||||
|
||||
/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry.
|
||||
pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10;
|
||||
|
||||
/// The longer side of the first-page picture saved in the file, in pixels.
|
||||
pub(super) const PREVIEW_PX: f64 = 320.0;
|
||||
|
||||
/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file
|
||||
/// in its recent files. A page that can't be drawn just leaves the picture out.
|
||||
pub(super) fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result<Vec<u8>> {
|
||||
let preview = d.page_loc(0).and_then(|(si, pi)| {
|
||||
let b = d.spreads.get(si)?.pages.get(pi)?.bounds();
|
||||
let side = b.width().max(b.height());
|
||||
(side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side)
|
||||
});
|
||||
let png =
|
||||
preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png());
|
||||
save_bytes(d, png.as_deref())
|
||||
}
|
||||
|
||||
pub fn from_bytes(b: &[u8]) -> Result<Document> {
|
||||
load_bytes(b).map(|(d, _)| d)
|
||||
}
|
||||
|
||||
/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the
|
||||
/// open, with a warning for each repair.
|
||||
pub(super) fn load_bytes(b: &[u8]) -> Result<(Document, Vec<String>)> {
|
||||
let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let warnings = d.fix_parent_bases();
|
||||
Ok((d, warnings))
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
pub fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for c in data.chunks(3) {
|
||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
||||
out.push(B64[(n >> 18) as usize & 63] as char);
|
||||
out.push(B64[(n >> 12) as usize & 63] as char);
|
||||
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn base64_decode(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let mut buf = 0u32;
|
||||
let mut bits = 0;
|
||||
for b in s.bytes() {
|
||||
let v = match b {
|
||||
b'A'..=b'Z' => b - b'A',
|
||||
b'a'..=b'z' => b - b'a' + 26,
|
||||
b'0'..=b'9' => b - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
_ => continue,
|
||||
} as u32;
|
||||
buf = buf << 6 | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod extension_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{is_document, with_document_extension};
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn the_document_extension_is_added_when_missing_or_different() {
|
||||
assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft");
|
||||
assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft");
|
||||
assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft");
|
||||
assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft");
|
||||
assert_eq!(with_document_extension("/a/.."), "/a/..");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_adds_the_extension_and_open_recognises_files_by_content() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
|
||||
let bare = dir.join("Brochure");
|
||||
let written = dir.join("Brochure.designcraft");
|
||||
let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}");
|
||||
assert!(written.is_file() && !bare.exists());
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str());
|
||||
let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}");
|
||||
|
||||
// A native file without the extension opens, and Save writes back to that same file.
|
||||
std::fs::rename(&written, &bare).unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap();
|
||||
let r = t.execute("file.save", &json!({})).unwrap();
|
||||
assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}");
|
||||
assert!(!written.exists());
|
||||
|
||||
// An IDML package without the extension opens as IDML, unsaved.
|
||||
let idml = dir.join("Package");
|
||||
std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||||
assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap()));
|
||||
// The start of the file is enough.
|
||||
for path in [&idml, &bare] {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}");
|
||||
}
|
||||
t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().path, None);
|
||||
|
||||
let notes = dir.join("Notes");
|
||||
std::fs::write(¬es, b"plain text").unwrap();
|
||||
assert!(!is_document(b"plain text"));
|
||||
let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
#[cfg(test)]
|
||||
use super::bytes::base64_encode;
|
||||
use super::*;
|
||||
|
||||
/// The longest saved preset name, and how many presets are kept.
|
||||
const MAX_PRESET_NAME: usize = 100;
|
||||
|
||||
const MAX_PRESETS: usize = 500;
|
||||
|
||||
/// A built-in document preset or a saved one, by name (case ignored).
|
||||
fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
|
||||
NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned())
|
||||
}
|
||||
|
||||
/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it.
|
||||
fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
|
||||
let mut nd = match str_param(p, "preset") {
|
||||
Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
|
||||
None => NewDocument::default(),
|
||||
};
|
||||
apply_settings(&mut nd, p);
|
||||
check_size("file.new", &nd)?;
|
||||
Ok(nd)
|
||||
}
|
||||
|
||||
/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a
|
||||
/// paragraph's last word or across a column. Opened and imported files keep their own values,
|
||||
/// which is why `ParaProps` keeps the IDML defaults (on).
|
||||
fn new_document(nd: &NewDocument) -> Document {
|
||||
let mut d = Document::new(nd);
|
||||
if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) {
|
||||
st.para.hyph_capitalized = Some(false);
|
||||
st.para.hyph_last_word = Some(false);
|
||||
st.para.hyph_across_column = Some(false);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub(super) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut nd = new_document_settings(s, p)?;
|
||||
s.untitled += 1;
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
|
||||
let d = new_document(&nd);
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// New Document's Preview: a document made as `file.new` would make it with `p`, shown while
|
||||
/// the New Document window is open. It replaces the previous preview document in its tab and
|
||||
/// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses
|
||||
/// leave the previous preview as it was. Returns the preview document's uid.
|
||||
pub fn preview_new_document(&mut self, p: &Value) -> Result<u64> {
|
||||
let mut nd = new_document_settings(self, p)?;
|
||||
let previous = match self.new_document_preview {
|
||||
Some((_, previous)) => previous,
|
||||
None => self.active().map(|d| d.uid),
|
||||
};
|
||||
let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old));
|
||||
// A preview edited or saved by a menu or script while the window is open has become a
|
||||
// document of its own: it stays, and the preview gets a tab of its own.
|
||||
let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none()));
|
||||
if replace.is_none() && shown.is_some() {
|
||||
self.untitled += 1;
|
||||
}
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
|
||||
let st = DocState::new(new_document(&nd), None);
|
||||
let uid = st.uid;
|
||||
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
|
||||
Some((i, d)) => {
|
||||
*d = st;
|
||||
self.active = Some(i);
|
||||
}
|
||||
None => {
|
||||
// Pushed without `add_document`: the document that was active stays as it is.
|
||||
self.docs.push(st);
|
||||
self.active = Some(self.docs.len() - 1);
|
||||
}
|
||||
}
|
||||
self.new_document_preview = Some((uid, previous));
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// The uid of New Document's preview document, while one is shown.
|
||||
pub fn new_document_preview(&self) -> Option<u64> {
|
||||
self.new_document_preview.map(|(uid, _)| uid)
|
||||
}
|
||||
|
||||
/// End New Document's preview. Its document goes, and if it was still the active document,
|
||||
/// the one that was active before it is active again. A preview that a menu or script has
|
||||
/// edited or saved stays open as a document, with an Untitled number. Returns the uid of the
|
||||
/// document removed. Without a preview it does nothing.
|
||||
pub fn end_new_document_preview(&mut self) -> Option<u64> {
|
||||
let (uid, previous) = self.new_document_preview.take()?;
|
||||
let i = self.docs.iter().position(|d| d.uid == uid)?;
|
||||
let d = self.docs.get(i)?;
|
||||
if d.is_dirty() || d.path.is_some() {
|
||||
self.untitled += 1;
|
||||
return None;
|
||||
}
|
||||
self.docs.remove(i);
|
||||
self.active = match self.active {
|
||||
Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)),
|
||||
Some(a) if a > i => Some(a - 1),
|
||||
a => a,
|
||||
};
|
||||
Some(uid)
|
||||
}
|
||||
|
||||
/// Create in the New Document window, with the settings the preview shows: the preview
|
||||
/// document becomes the new document, as `file.new` would make it (the next Untitled number,
|
||||
/// no undo history). Returns its index, or `None` when there is no unedited preview to keep.
|
||||
pub fn keep_new_document_preview(&mut self) -> Option<usize> {
|
||||
let (uid, _) = self.new_document_preview?;
|
||||
let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?;
|
||||
self.new_document_preview = None;
|
||||
self.untitled += 1;
|
||||
if let Some(d) = self.docs.get_mut(i) {
|
||||
d.history.undo.clear();
|
||||
d.history.redo.clear();
|
||||
}
|
||||
self.active = Some(i);
|
||||
Some(i)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string();
|
||||
if name.chars().count() > MAX_PRESET_NAME {
|
||||
return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters")));
|
||||
}
|
||||
if NewDocument::from_preset(&name).is_some() {
|
||||
return Err(bad("file.savePreset", format!("`{name}` is a built-in preset")));
|
||||
}
|
||||
let mut nd = NewDocument::default();
|
||||
apply_settings(&mut nd, p);
|
||||
check_size("file.savePreset", &nd)?;
|
||||
nd.title = name.clone();
|
||||
let presets = &mut s.prefs.document_presets;
|
||||
presets.retain(|x| !x.title.eq_ignore_ascii_case(&name));
|
||||
if presets.len() >= MAX_PRESETS {
|
||||
return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets")));
|
||||
}
|
||||
presets.push(nd);
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> {
|
||||
if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) {
|
||||
return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The document settings in `p` (as file.new takes them) over `nd`.
|
||||
fn apply_settings(nd: &mut NewDocument, p: &Value) {
|
||||
if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
nd.intent = intent;
|
||||
}
|
||||
if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
nd.units = units;
|
||||
}
|
||||
nd.width = f64_or(p, "width", nd.width);
|
||||
nd.height = f64_or(p, "height", nd.height);
|
||||
nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999);
|
||||
nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page);
|
||||
nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages);
|
||||
nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns);
|
||||
nd.gutter = f64_or(p, "gutter", nd.gutter);
|
||||
nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame);
|
||||
match p.get("margins") {
|
||||
Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)),
|
||||
Some(v @ Value::Object(_)) => {
|
||||
if let Ok(m) = serde_json::from_value(v.clone()) {
|
||||
nd.margins = m;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(b) = edges(p, "bleed") {
|
||||
nd.bleed = b;
|
||||
}
|
||||
if let Some(b) = edges(p, "slug") {
|
||||
nd.slug = b;
|
||||
}
|
||||
}
|
||||
|
||||
/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside].
|
||||
fn edges(p: &Value, key: &str) -> Option<[f64; 4]> {
|
||||
let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 };
|
||||
match p.get(key)? {
|
||||
Value::Array(a) => match a.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>()?.as_slice() {
|
||||
[t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]),
|
||||
_ => None,
|
||||
},
|
||||
v => v.as_f64().map(|b| [clean(b); 4]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_sample(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = crate::sample::magazine();
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod new_document_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn saved_presets_make_documents_by_name() {
|
||||
let mut s = Session::new();
|
||||
let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277,
|
||||
"pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50},
|
||||
"bleed": [9, 9, 0, 9], "primaryTextFrame": true});
|
||||
assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter");
|
||||
let listed = s.execute("file.presets", &json!({})).unwrap();
|
||||
let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect();
|
||||
assert_eq!(saved.len(), 1);
|
||||
assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters")));
|
||||
// By name, case ignored; parameters still override the preset.
|
||||
s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page_count(), 4);
|
||||
assert!(!d.settings.facing_pages && d.settings.primary_text_frame);
|
||||
assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters);
|
||||
assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]);
|
||||
assert_eq!(d.page(0).unwrap().margins.outside, 50.0);
|
||||
assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's");
|
||||
// The same name replaces it.
|
||||
s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap();
|
||||
assert_eq!(s.prefs.document_presets.len(), 1);
|
||||
s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
||||
// Built-in names, empty and overlong names and impossible sizes are refused.
|
||||
for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})]
|
||||
{
|
||||
assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}");
|
||||
}
|
||||
s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap();
|
||||
assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err());
|
||||
assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_document_takes_intent_units_start_page_bleed_and_slug() {
|
||||
let mut s = Session::new();
|
||||
let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18});
|
||||
s.execute("file.new", &p).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.settings.intent, designcraft_doc::Intent::Web);
|
||||
assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels);
|
||||
assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]);
|
||||
assert_eq!(d.settings.slug, [18.0; 4]);
|
||||
assert_eq!(d.page_name(0), "4");
|
||||
// Malformed edges are ignored, hostile ones bounded.
|
||||
s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.settings.bleed, [0.0; 4]);
|
||||
assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.recovery_dir = Some(dir.clone());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let first = s.doc().unwrap().uid;
|
||||
let a = s.preview_new_document(&json!({"width": 300})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2"));
|
||||
let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap();
|
||||
assert_eq!(s.documents().len(), 2, "the preview replaces itself");
|
||||
assert!(s.preview_new_document(&json!({"width": 0})).is_err());
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it");
|
||||
let listed = s.execute("document.list", &json!({})).unwrap();
|
||||
assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true)));
|
||||
assert_eq!(s.end_new_document_preview(), Some(b));
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0));
|
||||
assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing");
|
||||
// Edited by a script while shown, it writes no recovery data while it is a preview, and
|
||||
// stays as a document when the window closes, with its Untitled number.
|
||||
let c = s.preview_new_document(&json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0);
|
||||
// The app's timer saves through the command.
|
||||
assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0);
|
||||
assert!(crate::recovery::list(&dir).is_empty());
|
||||
assert_eq!(s.end_new_document_preview(), None);
|
||||
assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2"));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Untitled-3");
|
||||
// Create with the previewed settings keeps the preview as the document.
|
||||
let d = s.preview_new_document(&json!({"pages": 2})).unwrap();
|
||||
assert_eq!(s.keep_new_document_preview(), Some(3));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Untitled-5");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hyphenation_default_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::BASIC_PARAGRAPH;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) {
|
||||
let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default());
|
||||
(pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false));
|
||||
|
||||
let mut d = designcraft_doc::Document::new(&NewDocument::default());
|
||||
let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap();
|
||||
st.para.hyph_capitalized = Some(true);
|
||||
st.para.hyph_last_word = Some(true);
|
||||
st.para.hyph_across_column = Some(true);
|
||||
let b64 = super::base64_encode(&designcraft_idml::export_idml(&d));
|
||||
s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap();
|
||||
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
#[cfg(test)]
|
||||
use super::bytes::{PREVIEW_PX, to_bytes};
|
||||
use super::bytes::{base64_decode, load_bytes};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A recent file's first-page picture as straight RGBA, for the start screen.
|
||||
pub struct RecentPreview {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't
|
||||
/// ours, or was saved before files had one (it gets one when saved again).
|
||||
pub fn recent_preview(path: &str) -> Option<RecentPreview> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Only the zip directory and the preview entry are read, not the whole document.
|
||||
let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?;
|
||||
let img = designcraft_images::decode_rgba(&png)?;
|
||||
let (width, height) = img.dimensions();
|
||||
Some(RecentPreview { width, height, rgba: img.into_raw() })
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One File › Open Recent row: the path, its file name and whether it is still there.
|
||||
pub(super) fn recent_entry(path: &str) -> Value {
|
||||
let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let exists = crate::io::is_file(path);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let exists = false;
|
||||
json!({"path": path, "name": name, "exists": exists})
|
||||
}
|
||||
|
||||
pub(super) fn file_open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index =
|
||||
p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?;
|
||||
let path = s
|
||||
.prefs
|
||||
.recent_files
|
||||
.get(index)
|
||||
.cloned()
|
||||
.ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !crate::io::is_file(&path) {
|
||||
return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected")));
|
||||
}
|
||||
file_open(s, &json!({"path": path}))
|
||||
}
|
||||
|
||||
/// `file.open`: the document opens and moves to the front of File › Open Recent.
|
||||
pub(super) fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = open_path(s, p)?;
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
s.prefs.note_recent_file(path);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
// The content decides first: a file's extension may be missing or wrong. The extension
|
||||
// then routes files whose content isn't recognised to the reader that explains why.
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
|
||||
let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{");
|
||||
if designcraft_idml::is_idml(&bytes) || (!native && (crate::cmd::interchange::is_indd(path, &bytes) || ext == "idml")) {
|
||||
return crate::cmd::interchange::open_idml(s, p);
|
||||
}
|
||||
if !native && ext != designcraft_format::EXTENSION {
|
||||
return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file")));
|
||||
}
|
||||
let (mut d, mut warnings) = load_bytes(&bytes)?;
|
||||
crate::cmd::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path)));
|
||||
crate::cmd::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
|
||||
let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path);
|
||||
warnings.extend(font_warnings);
|
||||
warnings.extend(crate::cmd::style::grep_style_warnings(&d));
|
||||
let mut st = DocState::new(d, Some(path.to_string()));
|
||||
st.fonts = fonts;
|
||||
let i = s.add_document(st);
|
||||
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (s, path);
|
||||
Err(EngineError::Other("use file.openBytes on the web".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone
|
||||
/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each
|
||||
/// font file skipped). Nothing on the web (no folders).
|
||||
pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<crate::FontScope>>, usize, Vec<String>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) };
|
||||
// A document's folder is the agent's to name (a recovery entry or a book lists paths too):
|
||||
// the fonts crate asks the automation roots about the folder and each font file, so fonts
|
||||
// outside the read root stay unloaded.
|
||||
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p));
|
||||
d.font_scope = r.scope;
|
||||
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
|
||||
(scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (d, path);
|
||||
(None, 0, Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it
|
||||
/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf {
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
let exact = dir.join(DOCUMENT_FONTS_FOLDER);
|
||||
if crate::io::is_dir(&exact) {
|
||||
return exact;
|
||||
}
|
||||
crate::io::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path()))
|
||||
.map_or(exact, |e| e.path())
|
||||
}
|
||||
|
||||
pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
|
||||
// IDML packages are recognised by their stored `mimetype` first entry, INDD files by their
|
||||
// signature (or their name).
|
||||
if designcraft_idml::is_idml(&b) || crate::cmd::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) {
|
||||
return crate::cmd::interchange::open_idml(s, p);
|
||||
}
|
||||
let (mut d, warnings) = load_bytes(&b)?;
|
||||
crate::cmd::datamerge::resolve_sources_on_open(&mut d, None);
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
d.title = n.to_string();
|
||||
}
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i, "warnings": warnings}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod document_fonts_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use designcraft_fonts::testing::font_with;
|
||||
use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A new document in `s` with a text frame holding `text` set in `family`.
|
||||
fn new_document(s: &mut Session, family: &str, text: &str) {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
|
||||
}
|
||||
|
||||
/// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML).
|
||||
fn write_document(path: &Path, family: &str) {
|
||||
write_document_with(path, family, "Hello");
|
||||
}
|
||||
|
||||
fn write_document_with(path: &Path, family: &str, text: &str) {
|
||||
let mut s = Session::new();
|
||||
new_document(&mut s, family, text);
|
||||
if path.extension().is_some_and(|e| e == "idml") {
|
||||
std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||||
} else {
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn font_entry(s: &mut Session, family: &str) -> Value {
|
||||
let l = s.execute("font.list", &json!({})).unwrap();
|
||||
l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}"))
|
||||
}
|
||||
|
||||
/// Each character of the active document's text with the face it is drawn in.
|
||||
fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> {
|
||||
let st = s.doc().unwrap();
|
||||
let mut out = Vec::new();
|
||||
for (sid, story) in &st.doc.stories {
|
||||
let cs = s.cache.get(&st.doc, *sid, None);
|
||||
for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) {
|
||||
out.push((story.text[g.byte..].chars().next().unwrap(), g.face));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &Path) -> Value {
|
||||
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_document_loads_its_document_fonts() {
|
||||
const FAMILY: &str = "DocFont Open Test";
|
||||
let dir = temp_dir("designcraft");
|
||||
let fonts = dir.join(DOCUMENT_FONTS_FOLDER);
|
||||
std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap();
|
||||
let path = dir.join("Brochure.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
assert!(!FontDb::global().has_family(FAMILY));
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["documentFonts"], 1, "{r}");
|
||||
assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}");
|
||||
let f = font_entry(&mut s, FAMILY);
|
||||
assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_idml_file_loads_its_document_fonts() {
|
||||
const FAMILY: &str = "DocFont IDML Test";
|
||||
let dir = temp_dir("idml");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap();
|
||||
let path = dir.join("Brochure.idml");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["documentFonts"], 1, "{r}");
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], false);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indesign_file_says_how_to_bring_it_over() {
|
||||
// The extension decides (any case), not the bytes: no InDesign-made file is needed.
|
||||
let dir = temp_dir("indd");
|
||||
let path = dir.join("Brochure.INDD");
|
||||
std::fs::write(&path, b"not a DesignCraft document").unwrap();
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}");
|
||||
let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string();
|
||||
assert!(e.contains("InDesign Markup (IDML)"), "{e}");
|
||||
assert!(s.doc().is_err(), "nothing was opened");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_fonts_folder_is_found_whatever_its_case() {
|
||||
const FAMILY: &str = "DocFont Case Test";
|
||||
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fonts = dir.join("Document fonts");
|
||||
std::fs::create_dir_all(&fonts).unwrap();
|
||||
std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap();
|
||||
let path = dir.join("Brochure.idml");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, &path);
|
||||
assert_eq!(r["documentFonts"], 1, "{r}");
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], false);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_the_folder_opens_as_before() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Plain.designcraft");
|
||||
write_document(&path, designcraft_fonts::DEFAULT_FAMILY);
|
||||
let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
fn document_fonts_belong_to_their_document() {
|
||||
const FAMILY: &str = "DocFont Scoped";
|
||||
let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b"));
|
||||
std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
write_document(&a.join("A.designcraft"), FAMILY);
|
||||
write_document(&b.join("B.designcraft"), FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1);
|
||||
assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0);
|
||||
// B, open at the same time, doesn't have A's font.
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], true);
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s));
|
||||
s.execute("file.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["source"], "document");
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s));
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_document_sets_its_own_font_of_a_name() {
|
||||
const FAMILY: &str = "DocFont Same";
|
||||
let (a, b) = (temp_dir("same-a"), temp_dir("same-b"));
|
||||
let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"));
|
||||
// A's font has both letters, B's only `b`.
|
||||
std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap();
|
||||
std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap();
|
||||
// The same words in both: the word cache must not hand one document's shaping to the other.
|
||||
write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab");
|
||||
write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab");
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &a.join("A.designcraft"));
|
||||
open(&mut s, &b.join("B.designcraft"));
|
||||
// Fallback fonts for the `a` B's font lacks: never A's font.
|
||||
s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap();
|
||||
let in_b = glyph_faces(&s);
|
||||
s.execute("file.activate", &json!({"index": 0})).unwrap();
|
||||
let in_a = glyph_faces(&s);
|
||||
assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}");
|
||||
for (c, f) in &in_a {
|
||||
assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A");
|
||||
}
|
||||
for (c, f) in &in_b {
|
||||
match c {
|
||||
'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"),
|
||||
_ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"),
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() {
|
||||
const FAMILY: &str = "DocFont Closed";
|
||||
let dir = temp_dir("closed");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
let path = dir.join("Closed.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &path);
|
||||
let first = glyph_faces(&s)[0].1;
|
||||
assert_eq!(first.family, FAMILY);
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
// A new document can't use the closed document's font.
|
||||
new_document(&mut s, FAMILY, "Hello");
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], true);
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY));
|
||||
// Reopened, the document has its font again: the face loaded the first time.
|
||||
assert_eq!(open(&mut s, &path)["documentFonts"], 1);
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverted_document_keeps_its_fonts() {
|
||||
const FAMILY: &str = "DocFont Reverted";
|
||||
let dir = temp_dir("revert");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
let path = dir.join("Revert.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &path);
|
||||
s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["source"], "document");
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_link_the_document_fonts_styles() {
|
||||
const FAMILY: &str = "DocFont Linked";
|
||||
let dir = temp_dir("linked");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
let path = dir.join("Linked.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &path);
|
||||
let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||||
// The family is the document's alone: its Regular is the style without bold or italic.
|
||||
let r = s.execute("type.bold", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r["fontStyle"], "Regular", "{r}");
|
||||
let r = s.execute("type.italic", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r["fontStyle"], "Regular", "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod recent_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn saved(s: &mut Session, path: &std::path::Path) -> String {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Opening and saving put a file at the front once; Save a Copy doesn't count.
|
||||
#[test]
|
||||
fn open_and_save_order_the_list_without_duplicates() {
|
||||
let dir = temp_dir("order");
|
||||
let mut s = Session::new();
|
||||
let a = saved(&mut s, &dir.join("a.designcraft"));
|
||||
let b = saved(&mut s, &dir.join("b.designcraft"));
|
||||
assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]);
|
||||
s.execute("file.open", &json!({"path": a})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once");
|
||||
s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file");
|
||||
let rows = s.execute("file.recentFiles", &json!({})).unwrap();
|
||||
assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() {
|
||||
let dir = temp_dir("open");
|
||||
let mut s = Session::new();
|
||||
let a = saved(&mut s, &dir.join("a.designcraft"));
|
||||
let b = saved(&mut s, &dir.join("b.designcraft"));
|
||||
let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str()));
|
||||
assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]);
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be found"), "{e}");
|
||||
assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back");
|
||||
assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false);
|
||||
// Hostile indices are errors, never panics.
|
||||
for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] {
|
||||
assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}");
|
||||
}
|
||||
s.execute("file.clearRecent", &json!({})).unwrap();
|
||||
assert!(s.prefs.recent_files.is_empty());
|
||||
assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Saving puts a small picture of the first page in the file, which the start screen reads
|
||||
/// without opening the document.
|
||||
#[test]
|
||||
fn saved_files_carry_a_first_page_preview() {
|
||||
let dir = temp_dir("preview");
|
||||
let mut s = Session::new();
|
||||
let a = saved(&mut s, &dir.join("a.designcraft"));
|
||||
let p = super::recent_preview(&a).expect("a preview");
|
||||
assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32);
|
||||
assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize);
|
||||
assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent");
|
||||
let copy = dir.join("copy.designcraft");
|
||||
s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too");
|
||||
// Files without one (saved before previews), missing files and other files have none.
|
||||
let old = dir.join("old.designcraft");
|
||||
std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
for path in [old, dir.join("gone.designcraft"), dir.clone()] {
|
||||
assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_keeps_at_most_the_preferred_count() {
|
||||
let mut s = Session::new();
|
||||
for i in 0..30 {
|
||||
s.prefs.note_recent_file(&format!("/work/{i}.designcraft"));
|
||||
}
|
||||
assert_eq!(s.prefs.recent_files.len(), 20, "default: 20");
|
||||
assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft");
|
||||
s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]);
|
||||
assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES);
|
||||
assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err());
|
||||
s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap();
|
||||
s.prefs.note_recent_file("/work/new.designcraft");
|
||||
assert!(s.prefs.recent_files.is_empty(), "0 keeps no list");
|
||||
}
|
||||
|
||||
/// The list is a preference: it survives the round trip through prefs.json.
|
||||
#[test]
|
||||
fn the_list_survives_a_restart() {
|
||||
let mut s = Session::new();
|
||||
s.prefs.note_recent_file("/work/a.designcraft");
|
||||
let saved = serde_json::to_vec(&s.prefs).unwrap();
|
||||
let back: crate::Prefs = serde_json::from_slice(&saved).unwrap();
|
||||
assert_eq!(back.recent_files, ["/work/a.designcraft"]);
|
||||
assert_eq!(back.recent_files_max, 20);
|
||||
let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap();
|
||||
assert!(old.recent_files.is_empty(), "older prefs.json without the list still load");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
#[cfg(test)]
|
||||
use super::bytes::base64_encode;
|
||||
use super::bytes::{base64_decode, from_bytes};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The file's size and time before it is read (a linked text file remembers them).
|
||||
let mut before = None;
|
||||
let (bytes, name, link) = if let Some(b) = str_param(p, "base64") {
|
||||
(base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
before = crate::cmd::datamerge::fingerprint_from_path(path);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = vec![];
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
|
||||
(b, name, Some(path.to_string()))
|
||||
} else {
|
||||
s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) });
|
||||
return ok();
|
||||
};
|
||||
// A page of another layout (IDML or DesignCraft): its objects, as one group.
|
||||
let lname = name.to_lowercase();
|
||||
if lname.ends_with(".idml") || lname.ends_with(".designcraft") {
|
||||
return place_layout_page(s, p, &name, &bytes);
|
||||
}
|
||||
// Keep the EPS source for link comparison; rendering uses its preview at the bounding box size.
|
||||
let eps_size = eps_size(&bytes, "file.place")?;
|
||||
// Video and sound: a media frame (Window › Interactive › Media).
|
||||
if let Some(mime) = designcraft_doc::media_mime(&name) {
|
||||
return crate::cmd::media::place_media(s, p, name, mime, bytes, link);
|
||||
}
|
||||
// Text files (plain, Word, RTF) flow into frames.
|
||||
if designcraft_textimport::is_text_file(&name) {
|
||||
return crate::cmd::place_text::place_text(s, p, &name, &bytes, link, before);
|
||||
}
|
||||
let want_w = p
|
||||
.get("width")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|width| width.is_finite() && *width > 0.0)
|
||||
.ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero"))
|
||||
})
|
||||
.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);
|
||||
let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)"));
|
||||
let one_based = usize::try_from(n).map_err(|_| invalid_page())?;
|
||||
if one_based == 0 || one_based > count {
|
||||
return Err(invalid_page());
|
||||
}
|
||||
let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?;
|
||||
u32::try_from(zero_based).map_err(|_| invalid_page())?
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let (pw, ph) = match pdf_page {
|
||||
0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?,
|
||||
n => designcraft_render::pdf_page_size(&bytes, n as usize)
|
||||
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
|
||||
.ok_or_else(|| bad("file.place", "can't read that PDF page"))?,
|
||||
};
|
||||
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages
|
||||
// and SVGs keep their exact size: rounding it to whole points would stretch them unevenly.
|
||||
let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64));
|
||||
// Image Import Options › Crop to: the graphic spans that box of the PDF page.
|
||||
let is_pdf = designcraft_render::is_pdf(&bytes);
|
||||
let pdf_crop = match str_param(p, "pdfCrop") {
|
||||
Some(k) if is_pdf => PdfCrop::from_name(k)
|
||||
.ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?,
|
||||
_ => PdfCrop::Crop,
|
||||
};
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() };
|
||||
// The graphic's natural size: the box in points (the whole page when the box can't be found,
|
||||
// and then the document says so).
|
||||
let (bw, bh) = match crop_pdf(&mut asset) {
|
||||
Some(size) => size,
|
||||
None => {
|
||||
asset.pdf_crop = PdfCrop::Crop;
|
||||
(nw, nh)
|
||||
}
|
||||
};
|
||||
let target_frame = crate::cmd::id_param(p, "frame").or_else(|| {
|
||||
if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) {
|
||||
return None;
|
||||
}
|
||||
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(_))))
|
||||
});
|
||||
// `"spread": null` counts as omitted, like the other placement parameters.
|
||||
let spread = match p.get("spread") {
|
||||
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
|
||||
_ => crate::cmd::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);
|
||||
if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) {
|
||||
return load_cursor(s, asset, (bw, bh));
|
||||
}
|
||||
s.edit(|d, sel| {
|
||||
let fresh = AssetId(d.alloc());
|
||||
let aid = d.add_asset(Asset { id: fresh, ..asset });
|
||||
// The width the whole page (or image) gets; the height follows the proportions.
|
||||
let w = match want_w {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
let page_w = d.settings.page_width * 0.6;
|
||||
nw.min(page_w)
|
||||
}
|
||||
};
|
||||
let id = if let Some(fid) = target_frame {
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
let r = it.inner_bounds();
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
let k = (r.width() / bw).max(r.height() / bh);
|
||||
let (gw, gh) = (bw * k, bh * k);
|
||||
let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k);
|
||||
validate_placement((bw, bh), r, xf, "file.place")?;
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (bw, bh),
|
||||
xf,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
fid
|
||||
} else {
|
||||
let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?;
|
||||
let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0));
|
||||
let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0));
|
||||
let id = ItemId(d.alloc());
|
||||
let k = w / nw;
|
||||
let r = crate::cmd::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?;
|
||||
let xf = Affine::translate((x, y)) * Affine::scale(k);
|
||||
validate_placement((bw, bh), r, xf, "file.place")?;
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
it.object_style = d.styles.default_graphic_frame.clone();
|
||||
if let Some(e) = d.styles.object_effects(&it.object_style) {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (bw, bh),
|
||||
xf,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
d.insert_item(spread, it, None)?;
|
||||
id
|
||||
};
|
||||
*sel = Selection::items(vec![id]);
|
||||
Ok(json!({"id": id.0, "asset": aid.0}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in
|
||||
/// points. The crop box is the whole page and has none; nor has a page that can't be read, which
|
||||
/// keeps its crop choice (a missing link relinked later gets its box then).
|
||||
pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> {
|
||||
a.pdf_box = None;
|
||||
if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) {
|
||||
return None;
|
||||
}
|
||||
let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?;
|
||||
a.pdf_box = Some(frac);
|
||||
Some(size)
|
||||
}
|
||||
|
||||
pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)> {
|
||||
if let Some(b) = str_param(p, "base64") {
|
||||
return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = vec![];
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
|
||||
Ok((b, name, Some(path.to_string())))
|
||||
}
|
||||
|
||||
fn eps_size(bytes: &[u8], command: &str) -> Result<Option<(f64, f64)>> {
|
||||
if !designcraft_images::is_eps(bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
designcraft_images::eps_size(bytes)
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, "the EPS has a missing or invalid bounding box (dimensions must be 0.001–20,000 points)"))
|
||||
}
|
||||
|
||||
/// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls.
|
||||
pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> {
|
||||
let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0;
|
||||
if !size.0.is_finite()
|
||||
|| !size.1.is_finite()
|
||||
|| size.0 <= 0.0
|
||||
|| size.1 <= 0.0
|
||||
|| !valid_rect(frame)
|
||||
|| !xf.as_coeffs().iter().all(|n| n.is_finite())
|
||||
|| !valid_rect(xf.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1)))
|
||||
{
|
||||
return Err(bad(command, "image placement exceeds finite geometry limits"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, link) = read_source(p)?;
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?;
|
||||
let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64));
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() };
|
||||
load_cursor(s, asset, natural)
|
||||
}
|
||||
|
||||
/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor.
|
||||
fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result<Value> {
|
||||
let name = asset.name.clone();
|
||||
let (pw, ph) = asset.pixels.unwrap_or_default();
|
||||
let aid = s.edit(|d, _| {
|
||||
asset.id = AssetId(d.alloc());
|
||||
Ok(d.add_asset(asset))
|
||||
})?;
|
||||
s.loaded = Some(crate::LoadedGraphic { asset: aid, size });
|
||||
s.set_tool("placeGun");
|
||||
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true}))
|
||||
}
|
||||
|
||||
pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?;
|
||||
// Undo can remove the loaded graphic's asset.
|
||||
if !s.doc()?.doc.assets.contains_key(&aid) {
|
||||
s.loaded = None;
|
||||
return Err(bad("place.drop", "the loaded graphic is no longer in the document"));
|
||||
}
|
||||
let sr = crate::cmd::spread_param(p, "place.drop")?;
|
||||
let lid = s.doc()?.active_layer;
|
||||
let frame = crate::cmd::id_param(p, "frame");
|
||||
let rect = crate::cmd::rect_param(p, "rect");
|
||||
let (x, y) = (crate::cmd::f64_or(p, "x", 0.0), crate::cmd::f64_or(p, "y", 0.0));
|
||||
let d = &s.doc()?.doc;
|
||||
let (frame_rect, xf, auto_fit) = if let Some(fid) = frame {
|
||||
let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds();
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k);
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
(r, xf, Default::default())
|
||||
} else {
|
||||
if d.spread(sr).is_none() {
|
||||
return Err(bad("place.drop", "no such spread"));
|
||||
}
|
||||
// Drag: fit proportionally; click: actual size at the point.
|
||||
let (r, k) = match rect {
|
||||
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
|
||||
let k = (r.width() / nw).min(r.height() / nh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
|
||||
}
|
||||
_ => (Rect::new(x, y, x + nw, y + nh), 1.0),
|
||||
};
|
||||
(r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default())
|
||||
};
|
||||
validate_placement((nw, nh), frame_rect, xf, "place.drop")?;
|
||||
let r = s.edit(|d, sel| {
|
||||
let graphic = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf,
|
||||
clip: None,
|
||||
auto_fit,
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
let id = if let Some(fid) = frame {
|
||||
d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic;
|
||||
fid
|
||||
} else {
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect));
|
||||
it.object_style = d.styles.default_graphic_frame.clone();
|
||||
if let Some(e) = d.styles.object_effects(&it.object_style) {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
it.content = graphic;
|
||||
d.insert_item(sr, it, None)?;
|
||||
id
|
||||
};
|
||||
*sel = Selection::items(vec![id]);
|
||||
Ok(json!({"id": id.0}))
|
||||
})?;
|
||||
s.loaded = None;
|
||||
s.set_tool("selection");
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`,
|
||||
/// 1-based) grouped, top-left at x/y (default: the source position on the first page).
|
||||
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
const ID: &str = "file.place";
|
||||
let src = if name.to_lowercase().ends_with(".idml") {
|
||||
crate::cmd::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))?
|
||||
} else {
|
||||
from_bytes(bytes)?
|
||||
};
|
||||
let n = src.page_count();
|
||||
let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
if page == 0 || page > n {
|
||||
return Err(bad(ID, format!("the document has {n} page(s)")));
|
||||
}
|
||||
let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?;
|
||||
let sp = &src.spreads[si];
|
||||
let pr = sp.pages[pi].bounds();
|
||||
let ids: Vec<ItemId> = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(ID, format!("page {page} has no objects")));
|
||||
}
|
||||
let to = crate::cmd::spread_param(p, ID)?;
|
||||
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0),
|
||||
_ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0),
|
||||
};
|
||||
let placed = s.edit(|d, sel| {
|
||||
for sw in &src.swatches {
|
||||
if d.swatch(&sw.name).is_none() {
|
||||
d.swatches.push(sw.clone());
|
||||
}
|
||||
}
|
||||
let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect();
|
||||
let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect();
|
||||
if !missing_p.is_empty() || !missing_c.is_empty() {
|
||||
let st = d.styles_mut();
|
||||
st.paragraph.extend(missing_p);
|
||||
st.character.extend(missing_c);
|
||||
}
|
||||
let new = crate::cmd::object::duplicate_from(d, &src, &ids, to, off)?;
|
||||
*sel = Selection::items(new.clone());
|
||||
Ok(new)
|
||||
})?;
|
||||
if placed.len() > 1 {
|
||||
let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::<Vec<_>>()}))?;
|
||||
return Ok(json!({"id": g["id"], "items": placed.len()}));
|
||||
}
|
||||
Ok(json!({"id": placed[0].0, "items": 1}))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(super) mod image_place_tests {
|
||||
use designcraft_doc::{Content, ItemId};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
pub(in crate::cmd::file) fn image_file() -> std::io::Result<tempfile::NamedTempFile> {
|
||||
image_file_with_color(30)
|
||||
}
|
||||
|
||||
fn image_file_with_color(red: u8) -> std::io::Result<tempfile::NamedTempFile> {
|
||||
let file = tempfile::Builder::new().suffix(".png").tempfile()?;
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png();
|
||||
std::fs::write(file.path(), png)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_path_placements_create_distinct_graphics() {
|
||||
let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()];
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
|
||||
let mut placements = Vec::new();
|
||||
let mut originals = Vec::new();
|
||||
|
||||
for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) {
|
||||
let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap();
|
||||
let id = ItemId(placed["id"].as_u64().unwrap());
|
||||
assert!(
|
||||
placements.iter().all(|previous: &Value| previous["id"] != placed["id"]),
|
||||
"each explicit placement needs a distinct frame: {placed}"
|
||||
);
|
||||
let document = &session.doc().unwrap().doc;
|
||||
let item = document.item(id).unwrap();
|
||||
assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0));
|
||||
assert!(document.spreads[spread].items.iter().any(|item| item.id == id));
|
||||
originals.push(serde_json::to_value(item).unwrap());
|
||||
placements.push(placed);
|
||||
}
|
||||
|
||||
let document = &session.doc().unwrap().doc;
|
||||
assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3);
|
||||
assert_eq!(document.assets.len(), 3);
|
||||
for (placed, original) in placements.iter().zip(originals) {
|
||||
assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original);
|
||||
}
|
||||
let links = session.execute("links.list", &json!({})).unwrap();
|
||||
assert_eq!(links.as_array().unwrap().len(), 3);
|
||||
for (placed, image) in placements.into_iter().zip(&images) {
|
||||
let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap();
|
||||
assert_eq!(row["path"], json!(image.path()));
|
||||
assert_eq!(row["uses"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(row["uses"][0]["id"], placed["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_placement_parameters_bypass_selected_frames() {
|
||||
let image = image_file().unwrap();
|
||||
for graphic in [false, true] {
|
||||
for (key, value, expected) in [
|
||||
("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)),
|
||||
("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)),
|
||||
("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)),
|
||||
("width", 0, Rect::ZERO),
|
||||
("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)),
|
||||
] {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
|
||||
if graphic {
|
||||
session.execute("file.place", &json!({"path": image.path()})).unwrap();
|
||||
}
|
||||
let id = ItemId(selected["id"].as_u64().unwrap());
|
||||
let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap();
|
||||
let mut params = json!({"path": image.path()});
|
||||
params[key] = json!(value);
|
||||
|
||||
let result = session.execute("file.place", ¶ms);
|
||||
if key == "width" && value == 0 {
|
||||
assert!(result.is_err(), "zero width must be rejected, graphic={graphic}");
|
||||
continue;
|
||||
}
|
||||
let placed = result.unwrap();
|
||||
|
||||
assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}");
|
||||
let document = &session.doc().unwrap().doc;
|
||||
assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original);
|
||||
let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(item.bounds(), expected);
|
||||
let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") };
|
||||
let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs();
|
||||
assert!(scale_x > 0.0 && scale_y > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_frame_wins_over_placement_and_selection() {
|
||||
let image = image_file().unwrap();
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
|
||||
let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap();
|
||||
|
||||
let placed =
|
||||
session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap();
|
||||
|
||||
assert_eq!(placed["id"], target["id"]);
|
||||
let document = &session.doc().unwrap().doc;
|
||||
let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
|
||||
assert!(matches!(item.content, Content::Graphic(_)));
|
||||
assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned));
|
||||
assert_eq!(document.spreads[0].items.len(), 2);
|
||||
}
|
||||
|
||||
fn assert_width_rejected(width: Value) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let image = image_file()?;
|
||||
for graphic in [false, true] {
|
||||
for explicit_frame in [false, true] {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({}))?;
|
||||
let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?;
|
||||
if graphic {
|
||||
session.execute("file.place", &json!({"path": image.path()}))?;
|
||||
}
|
||||
let mut params = json!({"path": image.path(), "width": width});
|
||||
if explicit_frame {
|
||||
params["frame"] = target["id"].clone();
|
||||
session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?;
|
||||
}
|
||||
let before = session.doc()?;
|
||||
let document = before.doc.clone();
|
||||
let selection = before.selection.clone();
|
||||
let revision = before.revision;
|
||||
let assets = document.assets.len();
|
||||
|
||||
let result = session.execute("file.place", ¶ms);
|
||||
|
||||
assert!(
|
||||
matches!(&result, Err(crate::EngineError::BadParams { .. })),
|
||||
"width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}"
|
||||
);
|
||||
let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?;
|
||||
let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) };
|
||||
assert_eq!(cmd, "file.place");
|
||||
assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}");
|
||||
let after = session.doc()?;
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &after.doc));
|
||||
assert_eq!(after.selection, selection);
|
||||
assert_eq!(after.revision, revision);
|
||||
assert_eq!(after.doc.assets.len(), assets);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_rejected_when_zero() {
|
||||
assert_width_rejected(json!(0)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_rejected_when_negative() {
|
||||
assert_width_rejected(json!(-80)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_rejected_when_non_numeric() {
|
||||
for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] {
|
||||
assert_width_rejected(width).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let image = image_file()?;
|
||||
let previous_image = image_file_with_color(31)?;
|
||||
for graphic in [false, true] {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({}))?;
|
||||
let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?;
|
||||
let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?;
|
||||
let mut params = json!({"path": image.path()});
|
||||
for key in keys {
|
||||
params[*key] = Value::Null;
|
||||
}
|
||||
|
||||
let placed = session.execute("file.place", ¶ms)?;
|
||||
|
||||
assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}");
|
||||
if let Some(previous) = previous {
|
||||
assert_ne!(placed["asset"], previous["asset"]);
|
||||
}
|
||||
let document = &session.doc()?.doc;
|
||||
let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?);
|
||||
let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
|
||||
assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic");
|
||||
let Content::Graphic(content) = &item.content else {
|
||||
return Err(crate::EngineError::Other("placed frame must be graphic".into()).into());
|
||||
};
|
||||
assert_eq!(json!(content.asset.0), placed["asset"]);
|
||||
assert_eq!(document.spreads[0].items.len(), 1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_used_when_x_null() {
|
||||
assert_null_placement_uses_selection(&["x"]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_used_when_y_null() {
|
||||
assert_null_placement_uses_selection(&["y"]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_used_when_spread_null() {
|
||||
assert_null_placement_uses_selection(&["spread"]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_used_when_width_null() {
|
||||
assert_null_placement_uses_selection(&["width"]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_used_when_all_placement_parameters_null() {
|
||||
assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_placement_fills_empty_and_replaces_graphic_selection() {
|
||||
let image = image_file().unwrap();
|
||||
let previous_image = image_file_with_color(31).unwrap();
|
||||
for graphic in [false, true] {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap();
|
||||
let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap());
|
||||
|
||||
let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap();
|
||||
|
||||
assert_eq!(placed["id"], selected["id"]);
|
||||
if let Some(previous) = previous {
|
||||
assert_ne!(placed["asset"], previous["asset"]);
|
||||
}
|
||||
let document = &session.doc().unwrap().doc;
|
||||
let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0));
|
||||
let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") };
|
||||
assert_eq!(json!(content.asset.0), placed["asset"]);
|
||||
assert_eq!(document.spreads[0].items.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pdf_crop_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn place_pdf_cropped_to_its_trim_box() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap();
|
||||
let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({})).unwrap();
|
||||
let size = |t: &mut Session, crop: &str| {
|
||||
t.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
t.doc().unwrap().doc.item(id).unwrap().bounds()
|
||||
};
|
||||
let full = size(&mut t, "crop");
|
||||
let trim = size(&mut t, "trim");
|
||||
assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}");
|
||||
assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}");
|
||||
assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it,
|
||||
/// even in a frame larger than the graphic.
|
||||
#[test]
|
||||
fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() {
|
||||
// Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap();
|
||||
for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] {
|
||||
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone();
|
||||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||||
}
|
||||
let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({})).unwrap();
|
||||
// `width` is the whole page's (630 pt with its bleed): half size.
|
||||
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
let d = t.doc().unwrap().doc.clone();
|
||||
let it = d.item(id).unwrap();
|
||||
let g = it.graphic().unwrap().clone();
|
||||
let a = &d.assets[&g.asset];
|
||||
assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim);
|
||||
let b = a.shown_box().unwrap();
|
||||
let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0];
|
||||
assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}");
|
||||
assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size);
|
||||
let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
|
||||
let fb = it.bounds();
|
||||
for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] {
|
||||
assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}");
|
||||
}
|
||||
|
||||
// A frame larger than the graphic still shows only the trim box.
|
||||
t.edit(|d, _| {
|
||||
d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let d = t.doc().unwrap().doc.clone();
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
rr.threads = 0;
|
||||
let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60;
|
||||
// The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows
|
||||
// where it is on the page.
|
||||
assert!(!dark(97, 97), "the bleed is outside the trim box");
|
||||
assert!(!dark(103, 103), "the page is offset by its bleed");
|
||||
assert!(dark(110, 110), "the square inside the trim box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_keeps_its_exact_page_size() {
|
||||
// A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap();
|
||||
let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({})).unwrap();
|
||||
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
let d = &t.doc().unwrap().doc;
|
||||
let it = d.item(id).unwrap();
|
||||
let b = it.bounds();
|
||||
assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}");
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") };
|
||||
assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layout_place_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn place_a_page_of_an_idml_file_as_a_group() {
|
||||
let mut a = Session::new();
|
||||
a.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap();
|
||||
a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap();
|
||||
let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc);
|
||||
let b64 = super::base64_encode(&idml);
|
||||
let mut b = Session::new();
|
||||
b.execute("file.new", &json!({})).unwrap();
|
||||
let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap();
|
||||
assert_eq!(r["items"], 2);
|
||||
let d = &b.doc().unwrap().doc;
|
||||
let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(g.children().len(), 2, "one group");
|
||||
let bb = g.bounds();
|
||||
assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}");
|
||||
assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along");
|
||||
assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension};
|
||||
use super::new::{file_new, file_sample, save_preset};
|
||||
use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry};
|
||||
use super::place::{file_place, place_drop, place_load, read_source};
|
||||
use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir};
|
||||
use super::snippets::{snippet_export, snippet_place};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"),
|
||||
"{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}",
|
||||
always, file_new),
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| {
|
||||
let mut presets: Vec<Value> = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect();
|
||||
presets.extend(
|
||||
s.prefs
|
||||
.document_presets
|
||||
.iter()
|
||||
.map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})),
|
||||
);
|
||||
Ok(Value::Array(presets))
|
||||
}),
|
||||
cmd!(noundo "file.savePreset", "Save Document Preset", [], None,
|
||||
"{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused",
|
||||
always, save_preset),
|
||||
cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or_default().trim().to_string();
|
||||
let before = s.prefs.document_presets.len();
|
||||
s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name));
|
||||
if s.prefs.document_presets.len() == before {
|
||||
return Err(bad("file.deletePreset", format!("no saved preset `{name}`")));
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
|
||||
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}",
|
||||
always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes),
|
||||
cmd!(noundo "file.openRecent", "Open Recent File", [], None,
|
||||
"{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed",
|
||||
has_recent, file_open_recent),
|
||||
cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| {
|
||||
Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect()))
|
||||
}),
|
||||
cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| {
|
||||
s.prefs.recent_files.clear();
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?;
|
||||
let path = with_document_extension(path);
|
||||
let mut copy = (*s.doc()?.doc).clone();
|
||||
crate::cmd::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
|
||||
let bytes = to_bytes_with_preview(©, &s.cache)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
|
||||
cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p, FolderAccess::Read)?;
|
||||
Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect()))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p, FolderAccess::Write)?;
|
||||
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
|
||||
// Reopening removes the entries it read.
|
||||
let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?;
|
||||
Ok(json!({"opened": crate::recovery::open(s, &dir)?}))
|
||||
}),
|
||||
cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
// Preview fill is session state. Serialize the unfilled template.
|
||||
let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref());
|
||||
let bytes = to_bytes(doc)?;
|
||||
Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0);
|
||||
s.close_document(i);
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| {
|
||||
s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize);
|
||||
ok()
|
||||
}),
|
||||
cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None,
|
||||
"{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", crate::cmd::has_selection, snippet_export),
|
||||
cmd!(query "place.styles", "Import Options: Styles", [], None,
|
||||
"{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)",
|
||||
has_doc, |s, p| {
|
||||
let (bytes, name, _) = read_source(p)?;
|
||||
let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?;
|
||||
let st = &s.doc()?.doc.styles;
|
||||
let conflicts: Vec<&str> = imp
|
||||
.para_styles
|
||||
.iter()
|
||||
.filter(|x| st.para(&x.name).is_some())
|
||||
.chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some()))
|
||||
.map(|x| x.name.as_str())
|
||||
.collect();
|
||||
Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::<Vec<_>>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::<Vec<_>>(), "conflicts": conflicts}))
|
||||
}),
|
||||
cmd!(
|
||||
"snippet.place",
|
||||
"Place Snippet",
|
||||
[],
|
||||
None,
|
||||
"{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)",
|
||||
has_doc,
|
||||
snippet_place
|
||||
),
|
||||
cmd!(
|
||||
"place.load",
|
||||
"Load Place Cursor",
|
||||
[],
|
||||
None,
|
||||
"{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)",
|
||||
has_doc,
|
||||
place_load
|
||||
),
|
||||
cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop),
|
||||
cmd!(
|
||||
"file.place",
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+D"),
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`",
|
||||
has_doc,
|
||||
file_place
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::bytes::from_bytes;
|
||||
use super::bytes::{to_bytes_with_preview, with_document_extension};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::open::load_document_fonts;
|
||||
#[cfg(test)]
|
||||
use super::place::image_place_tests;
|
||||
use super::*;
|
||||
|
||||
pub(super) fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
// Saving to the document's own file keeps its name, even one opened without the extension.
|
||||
let path = str_param(p, "path")
|
||||
.map(with_document_extension)
|
||||
.or_else(|| st.path.clone())
|
||||
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
|
||||
// Before anything about the document changes: a refused save leaves it as it was.
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
|
||||
// Prepare paths for the destination without changing the live document until persistence succeeds.
|
||||
let mut saved = (*st.doc).clone();
|
||||
crate::cmd::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path));
|
||||
let paths_changed = saved.data_merge != st.doc.data_merge;
|
||||
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
|
||||
let bytes = to_bytes_with_preview(&saved, &s.cache)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;
|
||||
let st = s.doc_mut()?;
|
||||
if paths_changed {
|
||||
st.doc = saved;
|
||||
st.bump_revision();
|
||||
}
|
||||
st.path = Some(path.clone());
|
||||
st.saved_revision = st.revision;
|
||||
st.saved_doc = st.doc.clone();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let recovery_dir = s.recovery_dir.clone();
|
||||
let index = s.active_index().ok_or(EngineError::NoDocument)?;
|
||||
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
|
||||
s.prefs.note_recent_file(&path);
|
||||
}
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
/// Which access a recovery command needs to the folder it is given.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum FolderAccess {
|
||||
Read,
|
||||
Write,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
/// The recovery folder: `dir` when the command names one, else the session's own (set by the
|
||||
/// host application, so not the automation roots' business). A named folder is checked against
|
||||
/// the roots for `access`.
|
||||
pub(super) fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result<std::path::PathBuf> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (s, p, access);
|
||||
Err(EngineError::Other("no recovery folder on the web".into()))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(named) = str_param(p, "dir") else {
|
||||
return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)"));
|
||||
};
|
||||
if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) {
|
||||
crate::file_access::check_read(named).map_err(EngineError::Other)?;
|
||||
}
|
||||
if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) {
|
||||
crate::file_access::check_write(named).map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(std::path::PathBuf::from(named))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.active() {
|
||||
Some(d) if d.path.is_some() && d.is_dirty() => Ok(()),
|
||||
Some(d) if d.path.is_none() => Err("the document has never been saved".into()),
|
||||
Some(_) => Err("no changes since the last save".into()),
|
||||
None => Err("no document open".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?;
|
||||
crate::cmd::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)?
|
||||
};
|
||||
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
|
||||
let (fonts, _, _) = load_document_fonts(&mut d, &path);
|
||||
let uid = s.doc()?.uid;
|
||||
let recovery_dir = s.recovery_dir.clone();
|
||||
let index = s.active_index().ok_or(EngineError::NoDocument)?;
|
||||
let mut fresh = DocState::new(d, Some(path.clone()));
|
||||
fresh.fonts = fonts;
|
||||
fresh.uid = uid;
|
||||
// A new state has a revision no state had before, so caches don't show the old document.
|
||||
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
|
||||
*s.doc_mut()? = fresh;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (s, path);
|
||||
Err(EngineError::Other("revert isn't available on the web".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Saving over an existing document replaces it whole and leaves nothing beside it.
|
||||
#[test]
|
||||
fn save_replaces_the_existing_file_without_leftovers() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("doc.designcraft");
|
||||
std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(saved.stories.values().any(|st| st.text == "Saved whole"));
|
||||
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names, ["doc.designcraft"], "no temporary file is left");
|
||||
// A folder that doesn't exist: an error naming the path.
|
||||
let missing = dir.join("missing").join("doc.designcraft");
|
||||
let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A document that can't be written is an error: the file on disk, the unsaved state and the
|
||||
/// recovery checkpoint all stay as they were.
|
||||
#[test]
|
||||
fn a_save_that_cannot_write_the_document_changes_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("doc.designcraft");
|
||||
let recovery = dir.join("recovery");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap();
|
||||
s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap();
|
||||
let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid));
|
||||
assert!(checkpoint.exists());
|
||||
|
||||
super::FAIL_TO_BYTES.with(|f| f.set(true));
|
||||
let save = s.execute("file.save", &json!({}));
|
||||
let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()}));
|
||||
let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()}));
|
||||
let serialized = s.execute("file.serialize", &json!({}));
|
||||
super::FAIL_TO_BYTES.with(|f| f.set(false));
|
||||
|
||||
let e = save.unwrap_err();
|
||||
assert!(e.to_string().contains("could not write the document"), "{e}");
|
||||
assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched");
|
||||
assert!(!dir.join("copy.designcraft").exists());
|
||||
assert!(save_as.is_err() && !dir.join("other.designcraft").exists());
|
||||
assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file");
|
||||
assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes");
|
||||
assert!(checkpoint.exists(), "the recovery checkpoint stays");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a
|
||||
/// document holding a non-finite rect anyway, leaving the file on disk as it was.
|
||||
#[test]
|
||||
fn non_finite_coordinates_are_refused_and_never_saved() {
|
||||
let image = super::image_place_tests::image_file().unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err();
|
||||
assert!(e.to_string().contains("finite"), "{e}");
|
||||
assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed");
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("doc.designcraft");
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap());
|
||||
let st = s.doc_mut().unwrap();
|
||||
let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0);
|
||||
std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite);
|
||||
let e = s.execute("file.save", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("not a finite number"), "{e}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched");
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::bytes::{base64_encode, from_bytes, to_bytes};
|
||||
use super::place::read_source;
|
||||
use super::*;
|
||||
|
||||
/// The selection as `.designcraft` snippet bytes.
|
||||
pub(crate) fn snippet_bytes(s: &mut Session) -> Result<Vec<u8>> {
|
||||
to_bytes(&crate::cmd::edit::clip_doc(s)?)
|
||||
}
|
||||
|
||||
pub(super) fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = crate::cmd::edit::clip_doc(s)?;
|
||||
let bytes = to_bytes(&d)?;
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, _, _) = read_source(p)?;
|
||||
let src = from_bytes(&bytes)?;
|
||||
let ids: Vec<ItemId> = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("snippet.place", "the snippet is empty"));
|
||||
}
|
||||
let sr = crate::cmd::spread_param(p, "snippet.place")?;
|
||||
let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
|
||||
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0),
|
||||
_ => designcraft_geom::Vec2::ZERO,
|
||||
};
|
||||
s.edit(|d, sel| {
|
||||
// Bring over styles and swatches the snippet uses that this document lacks.
|
||||
for sw in &src.swatches {
|
||||
if d.swatch(&sw.name).is_none() {
|
||||
d.swatches.push(sw.clone());
|
||||
}
|
||||
}
|
||||
let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect();
|
||||
let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect();
|
||||
if !missing_p.is_empty() || !missing_c.is_empty() {
|
||||
let st = d.styles_mut();
|
||||
st.paragraph.extend(missing_p);
|
||||
st.character.extend(missing_c);
|
||||
}
|
||||
let new = crate::cmd::object::duplicate_from(d, &src, &ids, sr, off)?;
|
||||
*sel = Selection::items(new.clone());
|
||||
Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user