mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
Package: report PDF warnings and keep hyperlinks on rasterized frames
Include the PDF export warnings in the package result and report. Cover shadow and feather output in packaged PDFs with a synthetic regression test. Preserve frame and text-range hyperlinks on rasterized frames, including inside groups, at their original source bounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b17dd017d5
commit
5cd8d99db7
6 files changed
+252
-8
No files matched your search
@@ -147,7 +147,7 @@ pub(super) fn rasterize_effects(
|
||||
cache: &designcraft_compose::Cache,
|
||||
skip: &std::collections::HashMap<designcraft_doc::ItemId, designcraft_pdf::ShadowMask>,
|
||||
) -> (Option<designcraft_doc::Document>, usize) {
|
||||
rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id))
|
||||
rasterize_where_with_links(d, cache, 300.0, true, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id))
|
||||
}
|
||||
|
||||
/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself
|
||||
@@ -253,6 +253,17 @@ pub(super) fn rasterize_where(
|
||||
ppi: f64,
|
||||
keep_wrap: bool,
|
||||
pred: impl Fn(&designcraft_doc::Item) -> bool,
|
||||
) -> (Option<designcraft_doc::Document>, usize) {
|
||||
rasterize_where_with_links(d, cache, ppi, keep_wrap, false, pred)
|
||||
}
|
||||
|
||||
fn rasterize_where_with_links(
|
||||
d: &designcraft_doc::Document,
|
||||
cache: &designcraft_compose::Cache,
|
||||
ppi: f64,
|
||||
keep_wrap: bool,
|
||||
keep_links: bool,
|
||||
pred: impl Fn(&designcraft_doc::Item) -> bool,
|
||||
) -> (Option<designcraft_doc::Document>, usize) {
|
||||
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef};
|
||||
let todo: Vec<(usize, ItemId)> =
|
||||
@@ -320,11 +331,22 @@ pub(super) fn rasterize_where(
|
||||
img_item.stroke.weight = 0.0;
|
||||
img_item.alt_text = it.alt_text.clone();
|
||||
img_item.export_options = it.export_options.clone();
|
||||
let linked = keep_links
|
||||
&& d.hyperlinks.iter().any(|link| match &link.source {
|
||||
designcraft_doc::HyperlinkSource::Item { id: source } => d.top_level_of(*source) == Some(id),
|
||||
designcraft_doc::HyperlinkSource::Text { story, .. } => {
|
||||
d.story(*story).is_some_and(|st| st.frames.iter().any(|frame| d.top_level_of(*frame) == Some(id)))
|
||||
}
|
||||
});
|
||||
// Same place in the stacking order.
|
||||
let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);
|
||||
if let Some(pos) = osp.items.iter().position(|x| x.id == id) {
|
||||
osp.items[pos] = std::sync::Arc::new(img_item);
|
||||
if keep_wrap && has_wrap(it, 0) {
|
||||
if linked {
|
||||
// Annotation bounds and text composition still need the original frame, not
|
||||
// the image's larger effect bounds. This copy also keeps any text wrap.
|
||||
osp.items.insert(pos, std::sync::Arc::new(link_stand_in(it, 0)));
|
||||
} else if keep_wrap && has_wrap(it, 0) {
|
||||
osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0)));
|
||||
}
|
||||
n += 1;
|
||||
@@ -347,9 +369,29 @@ fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins.
|
||||
/// Nesting depth past which groups inside a rasterised object keep no stand-in children.
|
||||
const MAX_STAND_IN_DEPTH: usize = 64;
|
||||
|
||||
/// Nonprinting link geometry, retaining text stories and wrap but no interactive annotations.
|
||||
fn link_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item {
|
||||
use designcraft_doc::Content;
|
||||
let mut c = it.clone();
|
||||
c.nonprinting = true;
|
||||
c.form_field = None;
|
||||
c.button = None;
|
||||
c.media = None;
|
||||
if let Content::Group { items } = &it.content {
|
||||
c.content = Content::Group {
|
||||
items: if depth < MAX_STAND_IN_DEPTH {
|
||||
items.iter().map(|i| std::sync::Arc::new(link_stand_in(i, depth + 1))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and
|
||||
/// nothing else that reaches the PDF: no text, form field, button or media. The image of the
|
||||
/// object stands in for all of that, as it does without the copy.
|
||||
|
||||
@@ -376,3 +376,97 @@ fn export_story_as_text_and_rtf() {
|
||||
assert!(std::fs::read_to_string(&path).unwrap().starts_with("{\\rtf1"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn linked_effect_frame() -> (Session, designcraft_doc::ItemId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 72, "height": 72})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [10, 10, 30, 30], "content": "text", "text": "Link", "caret": false})).unwrap();
|
||||
let id = designcraft_doc::ItemId(frame["id"].as_u64().unwrap());
|
||||
s.execute("hyperlink.create", &json!({"url": "https://example.com", "ids": [id.0]})).unwrap();
|
||||
s.execute("object.feather", &json!({"ids": [id.0], "width": 2})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterized_links_leave_no_stand_in_for_other_formats() {
|
||||
let (s, _) = linked_effect_frame();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut unlinked = (**d).clone();
|
||||
unlinked.hyperlinks.clear();
|
||||
for keep_wrap in [false, true] {
|
||||
let (linked, n) = super::flatten::rasterize_where(d, &s.cache, 144.0, keep_wrap, |_| true);
|
||||
let (plain, plain_n) = super::flatten::rasterize_where(&unlinked, &s.cache, 144.0, keep_wrap, |_| true);
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(plain_n, 1);
|
||||
assert_eq!(linked.unwrap().spreads[0].items.len(), plain.unwrap().spreads[0].items.len(), "keep_wrap={keep_wrap}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_link_stand_in_is_inert(item: &designcraft_doc::Item) {
|
||||
assert!(item.nonprinting, "every stand-in item is nonprinting");
|
||||
assert!(item.form_field.is_none(), "stand-ins must not emit form fields");
|
||||
assert!(item.button.is_none(), "stand-ins must not emit buttons");
|
||||
assert!(item.media.is_none(), "stand-ins must not emit media");
|
||||
for child in item.children() {
|
||||
assert_link_stand_in_is_inert(child);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterized_links_clear_interactivity_on_frames() {
|
||||
let (mut s, id) = linked_effect_frame();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let item = d.item_mut(id).unwrap();
|
||||
item.button = Some(designcraft_doc::ButtonAction::GoToFirstPage);
|
||||
item.media = Some(Default::default());
|
||||
let story = item.text_frame().unwrap().story;
|
||||
let (copy, n) = super::rasterize_effects(&s.doc().unwrap().doc, &s.cache, &Default::default());
|
||||
assert_eq!(n, 1);
|
||||
let copy = copy.unwrap();
|
||||
let stand_in = copy.item(id).unwrap();
|
||||
assert_link_stand_in_is_inert(stand_in);
|
||||
assert_eq!(stand_in.text_frame().unwrap().story, story);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterized_links_clear_interactivity_in_nested_groups() {
|
||||
let (mut s, id) = linked_effect_frame();
|
||||
let other = s.execute("frame.create", &json!({"rect": [35, 10, 45, 20]})).unwrap()["id"].clone();
|
||||
let inner = s.execute("object.group", &json!({"ids": [id.0, other]})).unwrap()["id"].clone();
|
||||
let other = s.execute("frame.create", &json!({"rect": [35, 25, 45, 35]})).unwrap()["id"].clone();
|
||||
let group = s.execute("object.group", &json!({"ids": [inner, other]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.feather", &json!({"ids": [group], "width": 2})).unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let child = d.item_mut(id).unwrap();
|
||||
child.form_field = Some(designcraft_doc::FormField {
|
||||
kind: designcraft_doc::FieldKind::TextField,
|
||||
name: "Field".into(),
|
||||
value: String::new(),
|
||||
options: Vec::new(),
|
||||
multiline: false,
|
||||
required: false,
|
||||
font_size: 0.0,
|
||||
});
|
||||
child.button = Some(designcraft_doc::ButtonAction::GoToFirstPage);
|
||||
child.media = Some(Default::default());
|
||||
let story = child.text_frame().unwrap().story;
|
||||
let (copy, n) = super::rasterize_effects(&s.doc().unwrap().doc, &s.cache, &Default::default());
|
||||
assert_eq!(n, 1);
|
||||
let copy = copy.unwrap();
|
||||
assert_link_stand_in_is_inert(copy.item(designcraft_doc::ItemId(group)).unwrap());
|
||||
assert_eq!(copy.item(id).unwrap().text_frame().unwrap().story, story);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterized_links_keep_exactly_one_wrapping_stand_in() {
|
||||
let (mut s, id) = linked_effect_frame();
|
||||
s.execute("object.textWrap", &json!({"ids": [id.0], "mode": "boundingBox", "offset": 3})).unwrap();
|
||||
let before = s.doc().unwrap().doc.item(id).unwrap().wrap.clone();
|
||||
let (copy, n) = super::rasterize_effects(&s.doc().unwrap().doc, &s.cache, &Default::default());
|
||||
assert_eq!(n, 1);
|
||||
let copy = copy.unwrap();
|
||||
assert_eq!(copy.spreads[0].items.len(), 2);
|
||||
assert_eq!(copy.spreads[0].items.iter().filter(|it| it.nonprinting).count(), 1);
|
||||
assert_eq!(copy.item(id).unwrap().wrap, before);
|
||||
assert_ne!(before.mode, designcraft_doc::WrapMode::None);
|
||||
}
|
||||
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package…",
|
||||
["File"],
|
||||
None,
|
||||
"{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
|
||||
"{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report, warnings: PDF export warnings}",
|
||||
has_doc,
|
||||
package
|
||||
),
|
||||
@@ -452,9 +452,12 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
|
||||
}
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if let Some(params) = pdf {
|
||||
let path = package_output_path(&dir, &title, "pdf")?;
|
||||
root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?;
|
||||
let pdf = super::export::pdf_report(&packed, &s.cache, ¶ms)?;
|
||||
root.write(&path, &pdf.bytes)?;
|
||||
warnings = pdf.warnings;
|
||||
files.push(path);
|
||||
}
|
||||
// The printing instructions first, as the form gave them; then the report: fonts (missing
|
||||
@@ -481,10 +484,18 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
r += "\nPDF export warnings\n";
|
||||
for warning in &warnings {
|
||||
r += &format!(" {warning}\n");
|
||||
}
|
||||
}
|
||||
let report = dir.join("Instructions.txt");
|
||||
root.write(&report, r.as_bytes())?;
|
||||
files.push(report);
|
||||
Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r}))
|
||||
Ok(
|
||||
json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r, "warnings": warnings}),
|
||||
)
|
||||
}
|
||||
|
||||
fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1,5 +1,100 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn soft_effect_link_rects(bytes: &[u8]) -> Vec<Vec<f32>> {
|
||||
use hayro_syntax::object::{Array, Name};
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
|
||||
pdf.objects()
|
||||
.into_iter()
|
||||
.filter_map(|o| o.into_dict())
|
||||
.filter(|d| d.get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Link"))
|
||||
.map(|d| d.get::<Array<'_>>(b"Rect").unwrap().iter::<f32>().collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn check_soft_effect_links(packaged: bool) {
|
||||
for (text, grouped) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "LinkedEffects", "width": 144, "height": 144})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 100, 60], "content": "text", "text": "Visit us", "caret": false})).unwrap();
|
||||
if text {
|
||||
s.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap();
|
||||
s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap();
|
||||
} else {
|
||||
s.execute("hyperlink.create", &json!({"url": "https://example.com", "ids": [frame["id"]]})).unwrap();
|
||||
}
|
||||
let effect = if grouped {
|
||||
let other = s.execute("frame.create", &json!({"rect": [100, 80, 110, 90]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({"ids": [frame["id"], other["id"]]})).unwrap()["id"].clone();
|
||||
s.execute("transform.move", &json!({"ids": [group], "dx": 3, "dy": 4})).unwrap();
|
||||
group
|
||||
} else {
|
||||
frame["id"].clone()
|
||||
};
|
||||
let before = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
let before = soft_effect_link_rects(&super::super::base64_decode(before["base64"].as_str().unwrap()));
|
||||
assert_eq!(before.len(), 1);
|
||||
if !text {
|
||||
assert_eq!(before[0], if grouped { vec![23.0, 80.0, 103.0, 120.0] } else { vec![20.0, 84.0, 100.0, 124.0] });
|
||||
}
|
||||
s.execute("object.dropShadow", &json!({"on": true, "size": 6, "distance": 5, "ids": [effect]})).unwrap();
|
||||
let bytes = if packaged {
|
||||
s.execute("file.package", &json!({"dir": dir.path(), "pdf": true, "idml": false})).unwrap();
|
||||
std::fs::read(dir.path().join("LinkedEffects.pdf")).unwrap()
|
||||
} else {
|
||||
let result = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
super::super::base64_decode(result["base64"].as_str().unwrap())
|
||||
};
|
||||
assert_eq!(soft_effect_link_rects(&bytes), before, "text={text}, grouped={grouped}");
|
||||
assert_eq!(String::from_utf8_lossy(&bytes).matches("/Subtype/Image").count(), 2, "one image and its soft mask");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_pdf_preserves_soft_effect_link_bounds() {
|
||||
check_soft_effect_links(false);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn package_pdf_preserves_soft_effect_link_bounds() {
|
||||
check_soft_effect_links(true);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn package_pdf_preserves_soft_effects_and_reports_warnings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "SoftEffects", "width": 144, "height": 144})).unwrap();
|
||||
let shadow = s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap()["id"].clone();
|
||||
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [shadow]})).unwrap();
|
||||
s.execute("object.dropShadow", &json!({"on": true, "size": 6, "distance": 5, "ids": [shadow]})).unwrap();
|
||||
let feather = s.execute("frame.create", &json!({"rect": [80, 80, 120, 120], "shape": "ellipse"})).unwrap()["id"].clone();
|
||||
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [feather]})).unwrap();
|
||||
s.execute("object.feather", &json!({"width": 6, "ids": [feather]})).unwrap();
|
||||
let exported = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
let expected = super::super::base64_decode(exported["base64"].as_str().unwrap());
|
||||
|
||||
let packaged = s.execute("file.package", &json!({"dir": dir.path(), "pdf": true, "idml": false})).unwrap();
|
||||
|
||||
let actual = std::fs::read(dir.path().join("SoftEffects.pdf")).unwrap();
|
||||
let images = |bytes: &[u8]| String::from_utf8_lossy(bytes).matches("/Subtype/Image").count();
|
||||
assert_eq!(images(&expected), 4, "two images and their soft masks");
|
||||
assert_eq!(images(&actual), images(&expected), "package must rasterise the same objects as export");
|
||||
assert_eq!(packaged["warnings"], exported["warnings"]);
|
||||
let warnings = packaged["warnings"].as_array().unwrap();
|
||||
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("2 object(s) with soft effects")));
|
||||
let report = std::fs::read_to_string(dir.path().join("Instructions.txt")).unwrap();
|
||||
assert_eq!(packaged["report"], report);
|
||||
for warning in warnings {
|
||||
assert!(report.contains(warning.as_str().unwrap()), "{report}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocated_packages_rebind_embedded_image_links_on_open() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
@@ -344,6 +439,7 @@ fn package_copies_the_fallback_fonts_that_draw_text() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn packaged_fonts_keep_a_font_extension() {
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
|
||||
Reference in new issue
Block a user