mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
Merge upstream main into feat/layout-parity
Conflict: ROADMAP.md: took main's restructured roadmap; this branch's inventory changes (Keep Lines Together default, IDML defaults, drop caps, IDML Kashidas) go into docs/target-app-parity.md, where the inventory lives now Conflict: crates/format/src/lib.rs: load runs the format upgrade, then merges duplicate assets; kept both sets of tests
This commit is contained in:
@@ -75,6 +75,11 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
|
||||
let new_id = ItemId(dst.alloc());
|
||||
maps.items.insert(old, new_id);
|
||||
it.id = new_id;
|
||||
if let Some(m) = it.media.as_mut()
|
||||
&& let Some(p) = m.poster
|
||||
{
|
||||
m.poster = dst.adopt_asset(src, p).or(Some(p));
|
||||
}
|
||||
match &mut it.content {
|
||||
Content::Text(tf) => {
|
||||
let new_sid = if let Some(n) = maps.stories.get(&tf.story) {
|
||||
@@ -83,6 +88,15 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
|
||||
let n = StoryId(dst.alloc());
|
||||
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
|
||||
st.id = n;
|
||||
let mut assets = HashMap::new();
|
||||
for a in designcraft_doc::story_asset_refs(&st) {
|
||||
if let Some(na) = dst.adopt_asset(src, a)
|
||||
&& na != a
|
||||
{
|
||||
assets.insert(a, na);
|
||||
}
|
||||
}
|
||||
designcraft_doc::remap_story_assets(&mut st, &assets);
|
||||
st.frames.clear();
|
||||
dst.stories.insert(n, Arc::new(st));
|
||||
maps.stories.insert(tf.story, n);
|
||||
@@ -94,8 +108,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
|
||||
}
|
||||
}
|
||||
Content::Graphic(g) => {
|
||||
if let Some(a) = src.assets.get(&g.asset) {
|
||||
dst.assets.entry(g.asset).or_insert_with(|| a.clone());
|
||||
if let Some(a) = dst.adopt_asset(src, g.asset) {
|
||||
g.asset = a;
|
||||
}
|
||||
}
|
||||
Content::Group { items } => {
|
||||
@@ -355,21 +369,18 @@ fn place_image(
|
||||
};
|
||||
let (nw, nh) = (f64::from(pw), f64::from(ph));
|
||||
let mode = fitting_of(&options.fitting);
|
||||
let aid = AssetId(doc.alloc());
|
||||
let fresh = AssetId(doc.alloc());
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
|
||||
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
|
||||
doc.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let aid = doc.add_asset(Asset {
|
||||
id: fresh,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
page: 0,
|
||||
});
|
||||
let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] };
|
||||
if let Some(xf) = g.fitted(inner, mode) {
|
||||
g.xf = xf;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
|
||||
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
|
||||
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}",
|
||||
always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
|
||||
@@ -313,8 +313,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.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.
|
||||
let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64));
|
||||
// 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 frame shows that box of the PDF page.
|
||||
let crop_box = match str_param(p, "pdfCrop") {
|
||||
Some(k) if designcraft_render::is_pdf(&bytes) => {
|
||||
@@ -336,9 +337,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let py = p.get("y").and_then(Value::as_f64);
|
||||
let want_w = p.get("width").and_then(Value::as_f64);
|
||||
s.edit(|d, sel| {
|
||||
let aid = AssetId(d.alloc());
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }));
|
||||
let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) });
|
||||
// The width the whole page (or image) gets; the height follows the proportions.
|
||||
let w = match want_w {
|
||||
Some(w) => w,
|
||||
@@ -350,14 +351,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 frame proportionally.
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
let (gw, gh) = (nw * k, nh * k);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
@@ -440,16 +441,13 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
|
||||
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 = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64));
|
||||
let aid = s.edit(|d, _| {
|
||||
let aid = AssetId(d.alloc());
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }),
|
||||
);
|
||||
Ok(aid)
|
||||
Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }))
|
||||
})?;
|
||||
s.loaded = Some((aid, (pw as f64, ph as f64)));
|
||||
s.loaded = Some((aid, natural));
|
||||
s.set_tool("placeGun");
|
||||
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
|
||||
}
|
||||
@@ -471,7 +469,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
@@ -689,6 +687,24 @@ mod pdf_crop_tests {
|
||||
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());
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path",
|
||||
has_doc, export_idml),
|
||||
cmd!(noundo "file.openIdml", "Open IDML", [], None,
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}",
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings: package parts missing, font files skipped}",
|
||||
always, open_idml),
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,11 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
import_report(bytes, dir).map(|(document, _)| document)
|
||||
}
|
||||
|
||||
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
|
||||
pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
@@ -49,7 +54,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
None
|
||||
}
|
||||
};
|
||||
let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let designcraft_idml::Imported { document, warnings } =
|
||||
designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let document = {
|
||||
let mut document = document;
|
||||
@@ -62,7 +68,7 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
resolve_packaged_links(&mut document, dir);
|
||||
document
|
||||
};
|
||||
Ok(document)
|
||||
Ok((document, warnings))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -142,14 +148,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
return Err(bad("file.openIdml", "missing `path` or `base64`"));
|
||||
};
|
||||
let mut d = import(&bytes, dir.as_deref())?;
|
||||
let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?;
|
||||
if let Some(n) = name {
|
||||
d.title = n;
|
||||
}
|
||||
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
|
||||
let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
|
||||
Some(path) => super::file::load_document_fonts(&mut d, path),
|
||||
None => (None, 0, Vec::new()),
|
||||
};
|
||||
warnings.extend(font_warnings);
|
||||
// Never save over the .idml with the native format: the document starts unsaved.
|
||||
let mut st = DocState::new(d, None);
|
||||
st.fonts = fonts;
|
||||
|
||||
@@ -112,8 +112,10 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
|
||||
}
|
||||
|
||||
fn list(d: &Document) -> Vec<Value> {
|
||||
let used = d.used_assets();
|
||||
d.assets
|
||||
.values()
|
||||
.filter(|a| used.contains(&a.id))
|
||||
.map(|a| {
|
||||
let uses: Vec<Value> = uses(d, a.id)
|
||||
.into_iter()
|
||||
@@ -311,3 +313,118 @@ mod relink_folder_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_asset_tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_of_one_file_share_an_asset_and_saves_drop_replaced_ones() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = super::super::file::base64_encode(&png(10, 10));
|
||||
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 72, "y": 72})).unwrap();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 200, "y": 72})).unwrap();
|
||||
let l = s.execute("links.list", &json!({})).unwrap();
|
||||
assert_eq!(l.as_array().unwrap().len(), 1);
|
||||
assert_eq!(l[0]["uses"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.doc().unwrap().doc.assets.len(), 1);
|
||||
|
||||
// A new image into a frame: the old asset stays in memory (undo) but not in the file.
|
||||
let f = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = super::super::file::base64_encode(&png(20, 10));
|
||||
let c = super::super::file::base64_encode(&png(30, 10));
|
||||
s.execute("file.place", &json!({"base64": b, "name": "b.png", "frame": f})).unwrap();
|
||||
s.execute("file.place", &json!({"base64": c, "name": "c.png", "frame": f})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.assets.len(), 3);
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
let mut names: Vec<&str> = back.assets.values().map(|a| a.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.png", "c.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_identical_bytes_at_different_paths_keep_their_own_links() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-same-bytes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b) = (dir.join("a.png"), dir.join("b.png"));
|
||||
std::fs::write(&a, png(10, 10)).unwrap();
|
||||
std::fs::write(&b, png(10, 10)).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for (path, x) in [(&a, 72), (&b, 200)] {
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.assets.len(), 2);
|
||||
let rows = |s: &mut Session| -> Vec<(String, Value)> {
|
||||
let l = s.execute("links.list", &json!({})).unwrap();
|
||||
l.as_array().unwrap().iter().map(|r| (r["path"].as_str().unwrap().to_string(), r["pixels"].clone())).collect()
|
||||
};
|
||||
let paths = |r: &[(String, Value)]| r.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>();
|
||||
let before = rows(&mut s);
|
||||
assert_eq!(paths(&before), [a.to_string_lossy(), b.to_string_lossy()]);
|
||||
|
||||
// b.png changes on disk: updating it leaves a.png's placement alone.
|
||||
std::fs::write(&b, png(20, 10)).unwrap();
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
let after = rows(&mut s);
|
||||
assert_eq!(paths(&after), paths(&before));
|
||||
assert_eq!((after[0].1.clone(), after[1].1.clone()), (json!([10, 10]), json!([20, 10])));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_graphics_reuse_the_identical_asset_of_the_target() {
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef};
|
||||
let asset = |id: u64, data: Vec<u8>| Asset {
|
||||
id: AssetId(id),
|
||||
name: "p.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(data),
|
||||
pixels: None,
|
||||
page: 0,
|
||||
};
|
||||
let mut src = Document::new(&NewDocument::default());
|
||||
let mut dst = Document::new(&NewDocument::default());
|
||||
let (pa, pb) = (png(4, 4), png(8, 4));
|
||||
// The source's asset id is taken in the target by a different file.
|
||||
let sid = AssetId(src.alloc());
|
||||
src.assets.insert(sid, Arc::new(asset(sid.0, pa.clone())));
|
||||
dst.assets.insert(sid, Arc::new(asset(sid.0, pb)));
|
||||
dst.alloc();
|
||||
let same = AssetId(dst.alloc());
|
||||
assert_ne!(same, sid);
|
||||
dst.assets.insert(same, Arc::new(asset(same.0, pa)));
|
||||
let iid = ItemId(src.alloc());
|
||||
let mut it = Item::new(
|
||||
iid,
|
||||
src.default_layer(),
|
||||
Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
|
||||
);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: sid,
|
||||
size: (4.0, 4.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [1.0; 4],
|
||||
});
|
||||
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
|
||||
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap();
|
||||
assert_eq!((g.asset, g.crop), (same, [1.0; 4]));
|
||||
assert_eq!(dst.assets.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,16 @@ use crate::{Result, Session};
|
||||
|
||||
fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item {
|
||||
let (svg, (w, h)) = crate::math::to_svg(latex, size);
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }),
|
||||
);
|
||||
let fresh = AssetId(d.alloc());
|
||||
let aid = d.add_asset(Asset {
|
||||
page: 0,
|
||||
id: fresh,
|
||||
name: "math.svg".into(),
|
||||
mime: "image/svg+xml".into(),
|
||||
link: None,
|
||||
data: Arc::new(svg),
|
||||
pixels: None,
|
||||
});
|
||||
let id = ItemId(d.alloc());
|
||||
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
|
||||
@@ -25,8 +25,8 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
|
||||
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
|
||||
let mime = mime.to_string();
|
||||
s.edit(|d, sel| {
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None }));
|
||||
let fresh = AssetId(d.alloc());
|
||||
let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None });
|
||||
let id = match target {
|
||||
Some(fid) => {
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
@@ -90,10 +90,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.edit(|d, _| {
|
||||
let poster_id = match poster {
|
||||
Some(Some((bytes, name, link, px))) => {
|
||||
let aid = AssetId(d.alloc());
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }));
|
||||
Some(Some(aid))
|
||||
Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })))
|
||||
}
|
||||
Some(None) => Some(None),
|
||||
None => None,
|
||||
|
||||
@@ -900,6 +900,11 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp
|
||||
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
|
||||
it.id = ItemId(dst.alloc());
|
||||
let my_id = it.id;
|
||||
if let Some(m) = it.media.as_mut()
|
||||
&& let Some(p) = m.poster
|
||||
{
|
||||
m.poster = dst.adopt_asset(src, p).or(Some(p));
|
||||
}
|
||||
match &mut it.content {
|
||||
Content::Text(tf) => {
|
||||
let new_sid = match stories.get(&tf.story) {
|
||||
@@ -908,6 +913,15 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
|
||||
let n = StoryId(dst.alloc());
|
||||
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
|
||||
st.id = n;
|
||||
let mut assets = HashMap::new();
|
||||
for a in designcraft_doc::story_asset_refs(&st) {
|
||||
if let Some(na) = dst.adopt_asset(src, a)
|
||||
&& na != a
|
||||
{
|
||||
assets.insert(a, na);
|
||||
}
|
||||
}
|
||||
designcraft_doc::remap_story_assets(&mut st, &assets);
|
||||
st.frames.clear();
|
||||
dst.stories.insert(n, Arc::new(st));
|
||||
stories.insert(tf.story, n);
|
||||
@@ -920,10 +934,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
|
||||
}
|
||||
}
|
||||
Content::Graphic(g) => {
|
||||
if let Some(a) = src.assets.get(&g.asset)
|
||||
&& !dst.assets.contains_key(&g.asset)
|
||||
{
|
||||
dst.assets.insert(g.asset, a.clone());
|
||||
if let Some(a) = dst.adopt_asset(src, g.asset) {
|
||||
g.asset = a;
|
||||
}
|
||||
}
|
||||
Content::Group { items } => {
|
||||
@@ -2463,8 +2475,8 @@ mod fitting_tests {
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap();
|
||||
let c = content(&s);
|
||||
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}");
|
||||
// Crop 10 pt on every side: the content spreads past the frame.
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap();
|
||||
// Crop 10 pt on every side and fit again: the content spreads past the frame.
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap();
|
||||
let c = content(&s);
|
||||
assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}");
|
||||
// Auto-fit: resizing the frame refits the content.
|
||||
|
||||
@@ -285,13 +285,13 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
|
||||
let mut out = d.clone();
|
||||
let mut taken = Vec::new();
|
||||
let mut written = Vec::new();
|
||||
let mut ids: Vec<AssetId> = d.assets.keys().copied().collect();
|
||||
ids.sort_by_key(|i| i.0);
|
||||
// Only files something places; the saved document leaves the others out.
|
||||
let ids = d.used_assets();
|
||||
for id in ids {
|
||||
if only.is_some_and(|o| !o.contains(&id)) {
|
||||
continue;
|
||||
}
|
||||
let a = &d.assets[&id];
|
||||
let Some(a) = d.assets.get(&id) else { continue };
|
||||
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
|
||||
let path = dir.join(&name);
|
||||
root.write(&path, a.data.as_slice())?;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::Write;
|
||||
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -20,91 +17,60 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn printers() -> Value {
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
|
||||
};
|
||||
let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
|
||||
// "system default destination: NAME"
|
||||
let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string);
|
||||
let printers = craft_print::printers();
|
||||
let default = printers.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let names: Vec<_> = printers.into_iter().map(|p| p.name).collect();
|
||||
json!({"printers": names, "default": default})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn printers() -> Value {
|
||||
json!({"printers": [], "default": null})
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// Even a dry run cannot construct a native spool command in the browser.
|
||||
let _ = opts;
|
||||
super::export::options(p, s.doc()?.doc.page_count())?;
|
||||
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
|
||||
let printer = str_param(p, "printer").map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut file = secure_print_file()?;
|
||||
let file_path = file.path().to_path_buf();
|
||||
let mut cmd: Vec<String> = vec!["lpr".into()];
|
||||
if let Some(pr) = &printer {
|
||||
cmd.extend(["-P".into(), pr.clone()]);
|
||||
}
|
||||
if copies > 1 {
|
||||
cmd.extend(["-#".into(), copies.to_string()]);
|
||||
}
|
||||
cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]);
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
|
||||
if dry {
|
||||
return Ok(out);
|
||||
}
|
||||
file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
|
||||
if !st.status.success() {
|
||||
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
print_with(s, p, craft_print::submit)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn secure_print_file() -> Result<tempfile::NamedTempFile> {
|
||||
let file = tempfile::Builder::new()
|
||||
.prefix("designcraft-print-")
|
||||
.suffix(".pdf")
|
||||
.tempfile()
|
||||
.map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.as_file()
|
||||
.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
fn print_with(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
|
||||
) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
|
||||
let job = craft_print::Job {
|
||||
printer: str_param(p, "printer").map(str::to_string),
|
||||
copies,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: d.title.clone(),
|
||||
..craft_print::Job::default()
|
||||
};
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()});
|
||||
if !dry {
|
||||
submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
}
|
||||
Ok(file)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn print_builds_the_spool_command() {
|
||||
@@ -112,37 +78,45 @@ mod tests {
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
|
||||
assert!(cmd.last().unwrap().ends_with(".pdf"));
|
||||
assert!(!std::path::Path::new(cmd.last().unwrap()).exists());
|
||||
let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
if craft_print::backend() == Some(craft_print::Backend::Cups) {
|
||||
assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]);
|
||||
assert!(command.contains(&"print-scaling=auto-fit"));
|
||||
assert!(!command.iter().any(|a| a.starts_with("sides=")));
|
||||
} else {
|
||||
assert_eq!(command[0], "powershell");
|
||||
}
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
|
||||
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn print_uses_distinct_ephemeral_files() {
|
||||
let first = super::secure_print_file().unwrap();
|
||||
let second = super::secure_print_file().unwrap();
|
||||
let first_path = first.path().to_path_buf();
|
||||
let second_path = second.path().to_path_buf();
|
||||
assert_ne!(first_path, second_path);
|
||||
drop((first, second));
|
||||
assert!(!first_path.exists());
|
||||
assert!(!second_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), unix))]
|
||||
#[test]
|
||||
fn print_file_is_private_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let file = super::secure_print_file().unwrap();
|
||||
let path = file.path().to_path_buf();
|
||||
assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600);
|
||||
drop(file);
|
||||
assert!(!path.exists());
|
||||
fn print_submits_the_selected_export_and_reports_refusal() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
|
||||
let expected = {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let options = super::super::export::options(¶ms, d.page_count()).unwrap();
|
||||
designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap()
|
||||
};
|
||||
let r = print_with(&mut s, ¶ms, |pdf, job| {
|
||||
// The production export options and full bytes reach the shared spooler boundary.
|
||||
assert_eq!(pdf, expected);
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
|
||||
assert_eq!(job.copies, 2);
|
||||
assert_eq!(job.duplex, None);
|
||||
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
|
||||
assert!(!job.title.is_empty());
|
||||
Ok("request id is Office-1".into())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let refusal = print_with(&mut s, ¶ms, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err();
|
||||
assert!(refusal.to_string().contains("printer offline"));
|
||||
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
|
||||
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,13 +716,18 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
|
||||
use designcraft_color::Color;
|
||||
match v {
|
||||
Value::String(s) => Color::from_hex(s),
|
||||
// One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose
|
||||
// components are all within 0..1 is read as fractions (the older form).
|
||||
Value::Array(a) if a.len() >= 3 => {
|
||||
let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
|
||||
Some(Color::rgb(f(0)?, f(1)?, f(2)?))
|
||||
let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 };
|
||||
Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
|
||||
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64);
|
||||
let v = [g("c")?, g("m")?, g("y")?, g("k")?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 };
|
||||
Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -1040,6 +1045,20 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percent_components_at_most_one_stay_percent() {
|
||||
use designcraft_color::Color;
|
||||
// {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %.
|
||||
let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}");
|
||||
// Fractions (every component within 0..1) keep working.
|
||||
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6);
|
||||
// RGB arrays follow the same rule: [1, 128, 255] is 0..255.
|
||||
let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") };
|
||||
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_groups_rename_every_use() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -834,7 +834,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
for key in ["row", "story", "table"] {
|
||||
if let Some(v) = p.get(key)
|
||||
&& !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())
|
||||
&& v.as_u64().is_none_or(|n| usize::try_from(n).is_err())
|
||||
{
|
||||
return Err(bad(ID, format!("{key} must be a non-negative integer")));
|
||||
}
|
||||
@@ -1402,20 +1402,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?;
|
||||
let fill = p.get("fit").and_then(Value::as_str) == Some("fill");
|
||||
s.edit(|d, sel| {
|
||||
let aid = designcraft_doc::AssetId(d.alloc());
|
||||
let fresh = designcraft_doc::AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
std::sync::Arc::new(designcraft_doc::Asset {
|
||||
page: 0,
|
||||
id: aid,
|
||||
name,
|
||||
mime,
|
||||
link,
|
||||
data: std::sync::Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
}),
|
||||
);
|
||||
let aid =
|
||||
d.add_asset(designcraft_doc::Asset { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) });
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?;
|
||||
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let (r, c) = t.owner(g.range.r0, g.range.c0);
|
||||
|
||||
Reference in New Issue
Block a user