mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 05:36:46 -06:00
M6: preserve PDF catalogs and safely apply layer choices (#1670)
Preserve existing PDF catalog entries and full object generations. Accept literal null trailer entries while failing closed on encrypted/hybrid/ambiguous or unreadable history. Validate visibility variants atomically before document edits, suppress stale unsupported canvas overrides, and preserve conservative content cropping without overcropping empty optional-content stubs. Local full CI: all 9 gates passed, 3063 tests passed and 6 skipped, all 16 WASM-safe crates checked. Independent qpdf/preservation/Poppler checks passed for17 supported inputs;6 refusal cases stayed refused. All11 additional Prev controls passed. GitHub CI and CodeRabbit passed on00d5fa5c; CodeRabbit confirmed the Prev finding addressed. Author history preserved; no native format changes.
This commit is contained in:
@@ -11,6 +11,14 @@
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
||||
- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
|
||||
leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported
|
||||
all-content crops retain the whole page rather than silently clipping hidden layers.
|
||||
- **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full
|
||||
object references; bounded incremental updates replace the existing catalog. Synthetic
|
||||
regression tests cover metadata, output-intent references, nonzero generations and
|
||||
forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change.
|
||||
|
||||
## Status (2026-10-01)
|
||||
|
||||
**Working today**
|
||||
|
||||
@@ -42,6 +42,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
|
||||
return Err(bad("object.layerOptions", "select a placed PDF"));
|
||||
}
|
||||
// Validate every selected PDF before editing any item or recording undo.
|
||||
// Repeated placements of the same source need only one rewrite check.
|
||||
if !hidden.is_empty() {
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(data) = placed_pdf(s, *id)
|
||||
&& checked.insert(std::sync::Arc::as_ptr(&data))
|
||||
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
|
||||
{
|
||||
return Err(bad(
|
||||
"object.layerOptions",
|
||||
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for id in &ids {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
@@ -63,31 +79,277 @@ pub(crate) mod tests {
|
||||
|
||||
/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half).
|
||||
pub(crate) fn two_layer_pdf() -> Vec<u8> {
|
||||
two_layer_pdf_generation(0)
|
||||
}
|
||||
|
||||
fn two_layer_pdf_generation(generation: i32) -> Vec<u8> {
|
||||
two_layer_pdf_options(generation, "", "")
|
||||
}
|
||||
|
||||
fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec<u8> {
|
||||
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
|
||||
let objs = [
|
||||
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]>>>>>>".to_string(),
|
||||
format!(
|
||||
"<</Type/Catalog/Lang(en-GB)/Metadata 7 0 R/OutputIntents[8 0 R]/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]{config}>>>>>>"
|
||||
),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
"<</Type/OCG/Name(Blue)>>".to_string(),
|
||||
"<</Type/OCG/Name(Red)>>".to_string(),
|
||||
"<</Type/Metadata/Subtype/XML/Length 4>>\nstream\n<x/>\nendstream".to_string(),
|
||||
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier(Test)>>".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
|
||||
for o in offs {
|
||||
t.push_str(&format!("{o:010} 00000 n\r\n"));
|
||||
t.push_str(&format!("{o:010} {generation:05} n\r\n"));
|
||||
}
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 {generation} R{trailer}>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
out.extend_from_slice(t.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_preserves_catalog_configuration_and_generations() {
|
||||
for generation in [0, 1] {
|
||||
let original = two_layer_pdf_generation(generation);
|
||||
let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
let appended = std::str::from_utf8(&updated[original.len()..]).unwrap();
|
||||
for token in [
|
||||
"/Lang(en-GB)".to_string(),
|
||||
format!("/Metadata 7 {generation} R"),
|
||||
format!("/OutputIntents[8 {generation} R]"),
|
||||
format!("/Locked[5 {generation} R]"),
|
||||
"/Name(Default)".into(),
|
||||
format!("/Root 1 {generation} R"),
|
||||
] {
|
||||
assert!(appended.contains(&token), "missing {token}: {appended}");
|
||||
}
|
||||
assert!(!appended.contains("9999000"));
|
||||
let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap();
|
||||
assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() {
|
||||
let original = two_layer_pdf();
|
||||
let text = std::str::from_utf8(&original).unwrap();
|
||||
let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
let mut stream_pdf = original[..offset].to_vec();
|
||||
let mut entries = vec![0, 0, 0, 0, 0, 255, 255];
|
||||
for number in 1..=9 {
|
||||
let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() };
|
||||
entries.push(1);
|
||||
entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes());
|
||||
entries.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
stream_pdf.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/Root 1 0 R/W[1 4 2]/Length {}>>\nstream\n", entries.len()).as_bytes());
|
||||
stream_pdf.extend_from_slice(&entries);
|
||||
stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
|
||||
let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] {
|
||||
let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9"));
|
||||
assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() {
|
||||
let original = two_layer_pdf();
|
||||
let text = std::str::from_utf8(&original).unwrap();
|
||||
let first = original.len();
|
||||
let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<</Size 9/Root 1 0 R/Prev {prev:010}>>\n");
|
||||
let main = first + link(0).len();
|
||||
let mut forward = original.clone();
|
||||
forward.extend_from_slice(link(main).as_bytes());
|
||||
let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes());
|
||||
forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
|
||||
let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
let mut cyclic = original;
|
||||
cyclic.extend_from_slice(link(first).as_bytes());
|
||||
cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
|
||||
assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none());
|
||||
}
|
||||
|
||||
fn add_pdf_revision(bytes: &mut Vec<u8>) {
|
||||
let text = std::str::from_utf8(bytes).unwrap();
|
||||
let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(
|
||||
format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Root 1 0 R/Prev {prev}>>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value {
|
||||
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100}))
|
||||
.unwrap()["id"]
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn pixel(s: &Session, x: u32) -> [u8; 4] {
|
||||
let mut renderer = designcraft_render::Renderer::new();
|
||||
renderer.threads = 0;
|
||||
renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() {
|
||||
for generation in [0, 1] {
|
||||
let bytes = two_layer_pdf_options(generation, "", "/Encrypt null");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let id = place(&mut s, &bytes, 0.0);
|
||||
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
|
||||
assert_eq!(pixel(&s, 25), [0, 0, 255, 255]);
|
||||
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() {
|
||||
let mut unsupported = two_layer_pdf();
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut unsupported);
|
||||
}
|
||||
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
|
||||
let good = place(&mut s, &two_layer_pdf(), 0.0);
|
||||
let bad = place(&mut s, &unsupported, 100.0);
|
||||
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().history.redo.is_empty());
|
||||
let before = s.execute("file.serialize", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let redo = s.doc().unwrap().history.redo.len();
|
||||
for ids in [json!([good, bad]), json!([bad, good])] {
|
||||
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("could not be hidden"));
|
||||
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
|
||||
for id in [&good, &bad] {
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true);
|
||||
}
|
||||
}
|
||||
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
|
||||
assert_eq!(pixel(&s, 175), [255, 0, 0, 255]);
|
||||
// A persisted/relinked choice can be unrewritable. Reset must still work.
|
||||
let bad_id = serde_json::from_value(bad.clone()).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas");
|
||||
assert!(s.execute("file.exportPdf", &json!({})).is_err());
|
||||
s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true);
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
|
||||
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
|
||||
let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}"));
|
||||
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}");
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}");
|
||||
}
|
||||
for trailer in ["", "/Prev null"] {
|
||||
let bytes = two_layer_pdf_options(0, "", trailer);
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}");
|
||||
}
|
||||
let mut revised = two_layer_pdf();
|
||||
let prev =
|
||||
std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::<usize>().unwrap();
|
||||
add_pdf_revision(&mut revised);
|
||||
let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>"));
|
||||
assert!(real_prev.contains(&format!("/Prev {prev}.0>>")));
|
||||
for bytes in [revised, real_prev.into_bytes()] {
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_null_trailer_cannot_bypass_hybrid_rejection() {
|
||||
for real_guard in ["", "/XRefStm 0"] {
|
||||
let bytes = two_layer_pdf_options(0, "", real_guard);
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
for fake in ["/Encrypt null", ""] {
|
||||
let comment = format!("% trailer <</Size 9/Root 1 0 R{fake}>>\n");
|
||||
let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1);
|
||||
assert!(inside.contains(&format!("xref\n{comment}")));
|
||||
// Repair-only comments inside a table are always refused, guard or not.
|
||||
assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none());
|
||||
// Before xref or after the dictionary, the real trailer decides.
|
||||
let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1);
|
||||
let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1);
|
||||
for source in [leading, trailing] {
|
||||
assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2);
|
||||
assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_all_still_measures_layerless_and_stub_properties() {
|
||||
use designcraft_doc::PdfCrop;
|
||||
for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<</OCGs[]/D<<>>>>"] {
|
||||
let content = "0 0 1 rg 0 0 50 100 re f";
|
||||
let objs = [
|
||||
format!("<</Type/Catalog/Pages 2 0 R{props}>>"),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>".into(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
|
||||
for at in offsets {
|
||||
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
assert!(designcraft_render::pdf_layers(&bytes).is_empty());
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() {
|
||||
use designcraft_doc::PdfCrop;
|
||||
let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", "");
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0));
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut bytes);
|
||||
}
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none());
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_layers_hide() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -207,7 +207,9 @@ fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
|
||||
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let placed =
|
||||
session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
|
||||
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
|
||||
// Commands now reject unrewritable choices. Simulate stale persisted state after relinking.
|
||||
let id: designcraft_doc::ItemId = serde_json::from_value(placed["id"].clone()).unwrap();
|
||||
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().doc).item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
let before = session.execute("file.serialize", &json!({})).unwrap();
|
||||
let result = session.execute("file.exportPdf", &json!({}));
|
||||
assert!(result.is_err(), "failed layer hiding must not export the original visible layers");
|
||||
|
||||
@@ -875,7 +875,13 @@ impl Renderer {
|
||||
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
|
||||
let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen };
|
||||
let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone());
|
||||
let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() };
|
||||
let data = if !hidden.is_empty() && is_pdf(&asset.data) {
|
||||
// Stale persisted/relinked choices must not reveal hidden source content.
|
||||
let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return };
|
||||
variant
|
||||
} else {
|
||||
asset.data.clone()
|
||||
};
|
||||
// A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space.
|
||||
let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
|
||||
@@ -1392,8 +1398,13 @@ pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) -
|
||||
PdfCrop::Crop => WHOLE,
|
||||
PdfCrop::ContentVisible => pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE),
|
||||
PdfCrop::ContentAll => {
|
||||
let all_on = pdf_hide_layers(bytes, &[]);
|
||||
pdf_bounds::page_content_bounds(all_on.as_deref().unwrap_or(bytes), page).unwrap_or(WHOLE)
|
||||
if pdf_layers::has_optional_content(&pdf) {
|
||||
// If all-on rewriting is unsupported, a visible-only box could cut
|
||||
// off hidden content. Keep the entire page conservatively instead.
|
||||
pdf_hide_layers(bytes, &[]).and_then(|all_on| pdf_bounds::page_content_bounds(&all_on, page)).unwrap_or(WHOLE)
|
||||
} else {
|
||||
pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE)
|
||||
}
|
||||
}
|
||||
PdfCrop::Art | PdfCrop::Trim | PdfCrop::Bleed | PdfCrop::Media => {
|
||||
let media = p.media_box();
|
||||
|
||||
+252
-45
@@ -6,20 +6,17 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use craft_pdf::syntax::Pdf;
|
||||
use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier};
|
||||
use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier};
|
||||
|
||||
/// (object number, name, on by default) of each layer.
|
||||
fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
|
||||
/// (object reference, name, on by default) of each layer.
|
||||
fn ocgs(pdf: &Pdf) -> Vec<(ObjRef, String, bool)> {
|
||||
let xref = pdf.xref();
|
||||
let Some(cat) = xref.get::<Dict<'_>>(xref.root_id()) else { return vec![] };
|
||||
let Some(ocp) = cat.get::<Dict<'_>>(b"OCProperties") else { return vec![] };
|
||||
let Some(list) = ocp.get::<Array<'_>>(b"OCGs") else { return vec![] };
|
||||
let cfg = ocp.get::<Dict<'_>>(b"D");
|
||||
let ids_in = |key: &[u8]| -> Vec<i32> {
|
||||
cfg.as_ref()
|
||||
.and_then(|c| c.get::<Array<'_>>(key))
|
||||
.map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).map(|r| r.obj_number).collect())
|
||||
.unwrap_or_default()
|
||||
let ids_in = |key: &[u8]| -> Vec<ObjRef> {
|
||||
cfg.as_ref().and_then(|c| c.get::<Array<'_>>(key)).map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).collect()).unwrap_or_default()
|
||||
};
|
||||
let base_off = cfg.as_ref().and_then(|c| c.get::<Name<'_>>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF");
|
||||
let (on, off) = (ids_in(b"ON"), ids_in(b"OFF"));
|
||||
@@ -31,73 +28,283 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
|
||||
.get::<craft_pdf::syntax::object::String<'_>>(b"Name")
|
||||
.map(|s| String::from_utf8_lossy(s.as_bytes()).to_string())
|
||||
.unwrap_or_default();
|
||||
let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) };
|
||||
Some((r.obj_number, name, visible))
|
||||
let visible = if base_off { on.contains(&r) } else { !off.contains(&r) };
|
||||
Some((r, name, visible))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether interpreting this file can depend on optional-content state. Empty/stub
|
||||
/// properties are layerless, but malformed group references with state overrides
|
||||
/// still require a conservative all-content crop when rewriting is unavailable.
|
||||
pub(crate) fn has_optional_content(pdf: &Pdf) -> bool {
|
||||
let Some(cat) = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()) else { return false };
|
||||
let Some(ocp) = cat.get::<Dict<'_>>(b"OCProperties") else { return false };
|
||||
let nonempty = |dict: &Dict<'_>, key: &[u8]| dict.get::<Array<'_>>(key).is_some_and(|a| a.raw_iter().next().is_some());
|
||||
nonempty(&ocp, b"OCGs") || ocp.get::<Dict<'_>>(b"D").is_some_and(|cfg| nonempty(&cfg, b"ON") || nonempty(&cfg, b"OFF"))
|
||||
}
|
||||
|
||||
/// The layers of a placed PDF: (name, visible by default).
|
||||
pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> {
|
||||
craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file
|
||||
/// has no such layers or can't be read.
|
||||
// Copy untouched dictionary entries as raw PDF tokens. In particular, do not reinterpret
|
||||
// strings, numbers or unknown extension keys. The parser bounds container nesting.
|
||||
fn patch_dict(dict: &Dict<'_>, updates: &[(&[u8], &[u8])]) -> Option<Vec<u8>> {
|
||||
use craft_pdf::syntax::reader::{Reader, ReaderExt};
|
||||
let data = if dict.is_empty() { b"<<>>".as_slice() } else { dict.data() };
|
||||
if data.len() > 4 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut reader = Reader::new(data);
|
||||
reader.forward_tag(b"<<")?;
|
||||
let mut out = b"<<".to_vec();
|
||||
let mut ended = false;
|
||||
for _ in 0..100_000 {
|
||||
reader.skip_white_spaces_and_comments();
|
||||
if reader.forward_tag(b">>").is_some() {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
let start = reader.offset();
|
||||
let name = reader.read_without_context::<Name<'_>>()?;
|
||||
reader.skip_white_spaces_and_comments();
|
||||
reader.skip::<MaybeRef<Object<'_>>>(false)?;
|
||||
if !updates.iter().any(|(key, _)| *key == name.as_ref()) {
|
||||
out.push(b' ');
|
||||
out.extend_from_slice(data.get(start..reader.offset())?);
|
||||
}
|
||||
}
|
||||
if !ended {
|
||||
return None;
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if !value.is_empty() {
|
||||
out.extend_from_slice(b" /");
|
||||
out.extend_from_slice(key);
|
||||
out.push(b' ');
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b" >>");
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn trailer_at(bytes: &[u8], offset: usize) -> Option<Dict<'_>> {
|
||||
use craft_pdf::syntax::reader::{Reader, ReaderExt};
|
||||
const MAX_TABLE_BYTES: usize = 4 * 1024 * 1024;
|
||||
let data = bytes.get(offset..)?;
|
||||
// Bound even whitespace/comment scanning and context-free dictionary parsing.
|
||||
let mut reader = Reader::new(data.get(..data.len().min(MAX_TABLE_BYTES))?);
|
||||
reader.skip_white_spaces_and_comments();
|
||||
if reader.forward_tag(b"xref").is_some() {
|
||||
// Walk table syntax, never a raw substring: a repairable file may contain
|
||||
// a fake "trailer" in a comment. Such a comment must not hide real guards.
|
||||
let mut ended = false;
|
||||
for _ in 0..100_000 {
|
||||
if reader.offset() > MAX_TABLE_BYTES {
|
||||
return None;
|
||||
}
|
||||
reader.skip_white_spaces();
|
||||
if reader.forward_tag(b"trailer").is_some() {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
reader.read_without_context::<u32>()?; // first object number
|
||||
reader.skip_white_spaces();
|
||||
let count = reader.read_without_context::<usize>()?;
|
||||
if count > MAX_TABLE_BYTES / 18 {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..count {
|
||||
reader.skip_white_spaces();
|
||||
reader.read_without_context::<u32>()?; // offset or next free object
|
||||
reader.skip_white_spaces();
|
||||
if reader.read_without_context::<u32>()? > 65535 {
|
||||
return None;
|
||||
}
|
||||
reader.skip_white_spaces();
|
||||
reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?;
|
||||
if reader.offset() > MAX_TABLE_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ended {
|
||||
return None;
|
||||
}
|
||||
reader.skip_white_spaces();
|
||||
} else {
|
||||
// An xref stream's dictionary also holds the trailer entries.
|
||||
reader.read_without_context::<ObjectIdentifier>()?;
|
||||
reader.skip_white_spaces_and_comments();
|
||||
}
|
||||
reader.read_without_context::<Dict<'_>>()
|
||||
}
|
||||
|
||||
// Null dictionary entries are absent in PDF. Keep references and malformed values
|
||||
// fail-closed: trailer_at's dictionaries have no context for resolving references.
|
||||
fn has_non_null_entry(dict: &Dict<'_>, key: &[u8]) -> bool {
|
||||
dict.contains_key(key) && !matches!(dict.get_raw::<Object<'_>>(key), Some(MaybeRef::NotRef(Object::Null(_))))
|
||||
}
|
||||
|
||||
/// `bytes` with exactly the layers named in `hidden` off (the others on).
|
||||
/// Returns `None` for unreadable, encrypted, hybrid-reference or over-budget updates.
|
||||
/// Untouched catalog/configuration entries and full object references are preserved.
|
||||
pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option<Vec<u8>> {
|
||||
crate::check_pdf_input_size(bytes.len()).ok()?;
|
||||
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
|
||||
let layers = ocgs(&pdf);
|
||||
if layers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let xref = pdf.xref();
|
||||
let cat = xref.get::<Dict<'_>>(xref.root_id())?;
|
||||
let pages = cat.get_ref(b"Pages")?;
|
||||
let all: Vec<String> = layers.iter().map(|(n, _, _)| format!("{n} 0 R")).collect();
|
||||
let off: Vec<String> = layers.iter().filter(|(_, name, _)| hidden.contains(name)).map(|(n, _, _)| format!("{n} 0 R")).collect();
|
||||
// A new catalog under a fresh object number, with the original's page tree.
|
||||
let root = 9_999_000;
|
||||
let mut out = bytes.to_vec();
|
||||
if !out.ends_with(b"\n") {
|
||||
out.push(b'\n');
|
||||
let tail = String::from_utf8_lossy(bytes.get(bytes.len().saturating_sub(1024)..)?);
|
||||
let prev = tail.rsplit_once("startxref")?.1.split_whitespace().next()?.parse::<usize>().ok()?;
|
||||
let trailer = trailer_at(bytes, prev)?;
|
||||
// Do not copy decrypted dictionaries into an encrypted file, or ignore a hybrid
|
||||
// xref table. Follow bounded Prev links because encryption can be inherited.
|
||||
let mut offset = Some(prev);
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
for _ in 0..32 {
|
||||
let Some(at) = offset else { break };
|
||||
if !visited.insert(at) {
|
||||
return None;
|
||||
}
|
||||
let t = trailer_at(bytes, at)?;
|
||||
if has_non_null_entry(&t, b"Encrypt") || has_non_null_entry(&t, b"XRefStm") {
|
||||
return None;
|
||||
}
|
||||
offset = t.get::<usize>(b"Prev");
|
||||
if offset.is_none() && has_non_null_entry(&t, b"Prev") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if offset.is_some() {
|
||||
return None;
|
||||
}
|
||||
let xref = pdf.xref();
|
||||
let root = xref.root_id();
|
||||
if root.obj_number <= 0 || !(0..=65535).contains(&root.gen_number) || trailer.get::<usize>(b"Size")? <= usize::try_from(root.obj_number).ok()? {
|
||||
return None;
|
||||
}
|
||||
let cat = xref.get::<Dict<'_>>(root)?;
|
||||
let ocp = cat.get::<Dict<'_>>(b"OCProperties")?;
|
||||
let cfg = ocp.get::<Dict<'_>>(b"D");
|
||||
let mut off = b"[".to_vec();
|
||||
for (id, name, _) in &layers {
|
||||
if hidden.contains(name) {
|
||||
off.extend_from_slice(format!("{} {} R ", id.obj_number, id.gen_number).as_bytes());
|
||||
}
|
||||
}
|
||||
off.push(b']');
|
||||
let empty = Dict::empty();
|
||||
let cfg = patch_dict(cfg.as_ref().unwrap_or(&empty), &[(b"BaseState", b"/ON"), (b"ON", b"[]"), (b"OFF", &off)])?;
|
||||
let ocp = patch_dict(&ocp, &[(b"D", &cfg)])?;
|
||||
let catalog = patch_dict(&cat, &[(b"OCProperties", &ocp)])?;
|
||||
let root_ref = format!("{} {} R", root.obj_number, root.gen_number);
|
||||
let prev_value = prev.to_string();
|
||||
let trailer = patch_dict(
|
||||
&trailer,
|
||||
&[
|
||||
(b"Root", root_ref.as_bytes()),
|
||||
(b"Prev", prev_value.as_bytes()),
|
||||
(b"Type", b""),
|
||||
(b"Length", b""),
|
||||
(b"Filter", b""),
|
||||
(b"DecodeParms", b""),
|
||||
(b"W", b""),
|
||||
(b"Index", b""),
|
||||
],
|
||||
)?;
|
||||
let mut out = bytes.to_vec();
|
||||
out.push(b'\n');
|
||||
let at = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{root} 0 obj\n<</Type/Catalog/Pages {} {} R/OCProperties<</OCGs[{}]/D<</BaseState/ON/OFF[{}]>>>>>>\nendobj\n",
|
||||
pages.obj_number,
|
||||
pages.gen_number,
|
||||
all.join(" "),
|
||||
off.join(" ")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
// Redefine the existing catalog: no arbitrary fresh object number or Size increase.
|
||||
out.extend_from_slice(format!("{} {} obj\n", root.obj_number, root.gen_number).as_bytes());
|
||||
out.extend_from_slice(&catalog);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
let xref_at = out.len();
|
||||
let prev = {
|
||||
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string();
|
||||
tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::<usize>().ok())?
|
||||
};
|
||||
out.extend_from_slice(
|
||||
format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<</Size {}/Root {root} 0 R/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n", root + 1)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(format!("xref\n{} 1\n{at:010} {:05} n\r\ntrailer\n", root.obj_number, root.gen_number).as_bytes());
|
||||
out.extend_from_slice(&trailer);
|
||||
out.extend_from_slice(format!("\nstartxref\n{xref_at}\n%%EOF\n").as_bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Shared copies of layer variants (by source address and hidden set).
|
||||
pub(crate) fn layered(data: &Arc<Vec<u8>>, hidden: &[String]) -> Arc<Vec<u8>> {
|
||||
pub(crate) fn layered(data: &Arc<Vec<u8>>, hidden: &[String]) -> Option<Arc<Vec<u8>>> {
|
||||
type Key = (usize, Vec<String>);
|
||||
static CACHE: Mutex<Option<HashMap<Key, Arc<Vec<u8>>>>> = Mutex::new(None);
|
||||
static CACHE: Mutex<Option<HashMap<Key, (Arc<Vec<u8>>, Option<Arc<Vec<u8>>>)>>> = Mutex::new(None);
|
||||
let key = (Arc::as_ptr(data) as usize, hidden.to_vec());
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let map = c.get_or_insert_with(HashMap::new);
|
||||
if let Some(v) = map.get(&key) {
|
||||
return v.clone();
|
||||
return v.1.clone();
|
||||
}
|
||||
if map.len() > 64 {
|
||||
map.clear();
|
||||
}
|
||||
let v = pdf_hide_layers(data, hidden).map(Arc::new).unwrap_or_else(|| data.clone());
|
||||
map.insert(key, v.clone());
|
||||
let v = pdf_hide_layers(data, hidden).map(Arc::new);
|
||||
map.insert(key, (data.clone(), v.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_pdf::syntax::reader::{Reader, ReaderExt};
|
||||
|
||||
#[test]
|
||||
fn trailer_lookup_does_not_take_guard_values_from_xref_comments() {
|
||||
for guard in ["/Encrypt 12 0 R", "/XRefStm 123"] {
|
||||
let bytes = format!("xref\n% trailer <</Size 9/Encrypt null>>\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9{guard}>>");
|
||||
assert!(trailer_at(bytes.as_bytes(), 0).is_none(), "repair-only table must fail closed: {guard}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailer_lookup_accepts_whitespace_and_bounded_table_layouts() {
|
||||
for eol in ["\n", "\r\n"] {
|
||||
let bytes = format!(" \r\nxref{eol}0 1{eol}0000000000 65535 f{eol}7 1{eol}0000000123 00001 n{eol}trailer{eol}<</Size 9/Root 1 0 R>>");
|
||||
assert_eq!(trailer_at(bytes.as_bytes(), 0).unwrap().get::<usize>(b"Size"), Some(9));
|
||||
}
|
||||
assert!(trailer_at(b"xref\n0 1\n0000000000 65535 f\ntrailer% comment\n<< /Size 1 >>", 0).is_none());
|
||||
let mut whitespace = vec![b' '; 4 * 1024 * 1024];
|
||||
whitespace.extend_from_slice(b"xref\n0 0\ntrailer<<>>");
|
||||
assert!(trailer_at(&whitespace, 0).is_none());
|
||||
for bytes in [b"xref\n0 999999999\n".as_slice(), b"xref\n0 1\n0000000000 99999 f\ntrailer<<>>", b"xref\n0 1\n0000000000 65535 x\ntrailer<<>>"]
|
||||
{
|
||||
assert!(trailer_at(bytes, 0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_trailer_entries_are_absent_but_references_stay_unsupported() {
|
||||
for key in ["Encrypt", "XRefStm"] {
|
||||
assert!(!has_non_null_entry(&Dict::empty(), key.as_bytes()));
|
||||
for (value, allowed) in [("null", true), ("12 0 R", false), ("<< /Filter /Standard >>", false), ("false", false)] {
|
||||
let input = format!("<</{key} {value}>>");
|
||||
let dict = Reader::new(input.as_bytes()).read_without_context::<Dict<'_>>().unwrap();
|
||||
assert_eq!(!has_non_null_entry(&dict, key.as_bytes()), allowed, "{key}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_patch_preserves_raw_values_and_bounds_updates() {
|
||||
let data = b"<</Unknown[(x) 12 1 R <ff>]/Base#53tate/OFF/Name(a(b)c)>>";
|
||||
let dict = Reader::new(data).read_without_context::<Dict<'_>>().unwrap();
|
||||
let out = patch_dict(&dict, &[(b"BaseState", b"/ON")]).unwrap();
|
||||
let text = std::str::from_utf8(&out).unwrap();
|
||||
assert!(text.contains("/Unknown[(x) 12 1 R <ff>]"));
|
||||
assert!(text.contains("/Name(a(b)c)"));
|
||||
assert!(!text.contains("/OFF"));
|
||||
assert!(text.contains("/BaseState /ON"));
|
||||
assert_eq!(patch_dict(&Dict::empty(), &[(b"BaseState", b"/ON")]).unwrap(), b"<< /BaseState /ON >>");
|
||||
let mut many = b"<< /Big (".to_vec();
|
||||
many.resize(4 * 1024 * 1024 + 1, b'x');
|
||||
many.extend_from_slice(b") >>");
|
||||
let dict = Reader::new(&many).read_without_context::<Dict<'_>>().unwrap();
|
||||
assert!(patch_dict(&dict, &[]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
- **Estimate:** 20–35 h.
|
||||
|
||||
### 19. Placed graphics depth (FF)
|
||||
- **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation
|
||||
references, redefining the existing catalog rather than choosing an arbitrary object number.
|
||||
Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim.
|
||||
- **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer
|
||||
comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the
|
||||
frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a
|
||||
|
||||
@@ -49,6 +49,12 @@ are recorded separately in ASSETS.md.
|
||||
DesignCraft retains placement, layer-name policy, document state and the application render
|
||||
cache. Render output is premultiplied RGBA8. Page boxes use the same crop, rotation and
|
||||
UserUnit transform as raster output, including fractional placed-PDF dimensions.
|
||||
Layer choices are validated for every selected PDF before any document/undo state changes;
|
||||
a selection containing an unsupported or layerless PDF is refused rather than partially edited.
|
||||
Stale persisted/relinked choices that cannot be honored suppress the graphic on canvas rather
|
||||
than reveal hidden source content; resetting to file defaults remains available. All-content
|
||||
cropping keeps the whole page when real optional-content state cannot be rewritten, while
|
||||
layerless files and empty/null optional-content properties retain measured bounds.
|
||||
Layered-PDF export stops on failed layer rewriting, missing pages or reported incomplete
|
||||
content rather than saving an unchanged or incomplete placed page. Shared diagnostics only
|
||||
report known backend warnings; they do not certify all PDF fidelity.
|
||||
|
||||
Reference in New Issue
Block a user