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:
KnottyDyesandClaude Opus 5.5 committed 2026-11-03 18:20:58 -07:00
1 parent b17dd017d5
commit 5cd8d99db7
6 files changed
+252 -8

No files matched your search

+45 -3
View File
@@ -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);
}
+14 -3
View File
@@ -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, &params)?.bytes)?;
let pdf = super::export::pdf_report(&packed, &s.cache, &params)?;
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> {
+96
View File
@@ -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;