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

+1
View File
@@ -399,6 +399,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change.
- **2026-11-02** · Packaged PDFs report their export warnings in the package result and Instructions.txt, and soft drop shadows and feathers in them are tested. Frame and text-range hyperlinks keep their source bounds when the frame is rasterized, including inside groups. No readiness percentage change.
- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change.
- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change.
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. Native and IDML content takes priority over a misleading `.indd` or `.indt` extension. No readiness percentage change.
+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;
+2 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (package PDF soft effects, hyperlinks and export warnings; PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
@@ -53,7 +53,7 @@ layer survive interchange; literal XML and round-trip tests are in
| Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% |
| XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% |
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% |
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% |
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | PDF export warnings appear in the result and report. Frame and text-range hyperlinks keep their source bounds through rasterization, including inside groups. Synthetic shadow/feather/link regression in `crates/engine/src/cmd/package/tests.rs` | 2% |
PDF/X-4 export warns about unembedded fonts that draw visible text, including inside placed PDF
forms; `designcraft-cli validate-pdf FILE.pdf` reports the same issues, with font names and pages