mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
PDF: distinguish all-on layer choices from file defaults
This commit is contained in:
committed by
Andee Collard
parent
2633fe8b5d
commit
8d295b101e
@@ -12,6 +12,9 @@
|
||||
|
||||
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** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults.
|
||||
Legacy commands keep their reset behavior; native saves require a capable reader only for
|
||||
the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change.
|
||||
- **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees,
|
||||
preserving native-edited layer order through round trips. Synthetic regression added.
|
||||
- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
|
||||
|
||||
@@ -31,6 +31,17 @@ fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool {
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only))
|
||||
}
|
||||
|
||||
fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f))
|
||||
}
|
||||
|
||||
fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| st.objects.iter().any(|object| item_any(&object.item, depth + 1, f))
|
||||
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f))
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|note| story_any(¬e.text, depth + 1, f))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Whether saving needs a reader that retains per-placement graphic layer settings.
|
||||
/// Exceeding the traversal budget conservatively requires the newer format.
|
||||
@@ -39,6 +50,15 @@ impl Document {
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, false))
|
||||
}
|
||||
|
||||
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly
|
||||
/// shows every layer (an override with an empty hidden list). Older readers would take that
|
||||
/// for "use the file's defaults". Exceeding the traversal budget conservatively says yes.
|
||||
pub fn has_explicit_all_on_pdf_layers(&self) -> bool {
|
||||
let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty();
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on))
|
||||
|| self.stories.values().any(|story| story_any(story, 0, &all_on))
|
||||
}
|
||||
|
||||
/// Placements outside the ordinary document-spread frame walk used by preflight.
|
||||
pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool {
|
||||
self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|
||||
|
||||
@@ -1398,6 +1398,10 @@ pub struct Item {
|
||||
/// Object › Object Layer Options: layers of a placed PDF that are hidden.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pdf_hidden_layers: Vec<String>,
|
||||
/// Explicit placed-PDF visibility, including an all-on override with no hidden names.
|
||||
/// False with an empty hidden list retains the file's own settings (legacy behavior).
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub pdf_layer_override: bool,
|
||||
/// Window › Interactive › Media: options of a placed video or sound.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<MediaOptions>,
|
||||
@@ -1430,7 +1434,15 @@ fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn has_pdf_layer_override(&self) -> bool {
|
||||
self.pdf_layer_override || !self.pdf_hidden_layers.is_empty()
|
||||
}
|
||||
|
||||
pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self {
|
||||
Item {
|
||||
id,
|
||||
@@ -1457,6 +1469,7 @@ impl Item {
|
||||
xml_tag: String::new(),
|
||||
button: None,
|
||||
pdf_hidden_layers: Vec::new(),
|
||||
pdf_layer_override: false,
|
||||
live_caption: None,
|
||||
form_field: None,
|
||||
export_options: ExportOptions::default(),
|
||||
|
||||
@@ -673,7 +673,7 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I
|
||||
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
|
||||
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
|
||||
let ids: Vec<designcraft_doc::ItemId> =
|
||||
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
|
||||
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| it.has_pdf_layer_override() && it.graphic().is_some())).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok((None, 0));
|
||||
}
|
||||
@@ -690,7 +690,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraf
|
||||
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
|
||||
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
|
||||
let png = designcraft_render::pdf_page_png_checked(&a.data, a.page as usize, side, &hidden, shown)
|
||||
let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?;
|
||||
let px = designcraft_render::image_size(&png);
|
||||
let aid = designcraft_doc::AssetId(out.alloc());
|
||||
|
||||
@@ -28,7 +28,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) };
|
||||
let bytes = designcraft_idml::export_idml_with(&st.doc, &opts);
|
||||
let layer_doc = with_pdf_layer_variants(&st.doc, opts.embed_images)?;
|
||||
let bytes = designcraft_idml::export_idml_with(layer_doc.as_ref().unwrap_or(&st.doc), &opts);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -39,6 +40,48 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each placement gets its own embedded variant so two frames sharing a PDF can
|
||||
/// choose different layers. The live document and original linked bytes stay untouched.
|
||||
fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Document>> {
|
||||
let ids: Vec<_> = doc
|
||||
.all_items()
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
doc.item(*id).is_some_and(|it| {
|
||||
it.has_pdf_layer_override()
|
||||
&& it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !embed {
|
||||
return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into()));
|
||||
}
|
||||
let mut out = doc.clone();
|
||||
for id in ids {
|
||||
let Some(it) = doc.item(id) else { continue };
|
||||
let Some(asset) = it.graphic().and_then(|g| doc.assets.get(&g.asset)) else { continue };
|
||||
let bytes = designcraft_render::pdf_hide_layers(&asset.data, &it.pdf_hidden_layers)
|
||||
.ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?;
|
||||
let mut variant = (**asset).clone();
|
||||
variant.id = designcraft_doc::AssetId(out.alloc());
|
||||
variant.data = std::sync::Arc::new(bytes);
|
||||
variant.link = None;
|
||||
let aid = variant.id;
|
||||
out.assets.insert(aid, std::sync::Arc::new(variant));
|
||||
if let Some(it) = out.item_mut(id) {
|
||||
if let designcraft_doc::Content::Graphic(g) = &mut it.content {
|
||||
g.asset = aid;
|
||||
}
|
||||
it.pdf_layer_override = false;
|
||||
it.pdf_hidden_layers.clear();
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from
|
||||
/// beside `package` (the package's file, if it has one), so the automation read root applies to
|
||||
/// the paths the package names, and its PDF crops resolved.
|
||||
|
||||
@@ -17,11 +17,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| {
|
||||
let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?;
|
||||
let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?;
|
||||
let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
|
||||
let item = s.doc()?.doc.item(id);
|
||||
let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
|
||||
let overridden = item.is_some_and(|i| i.has_pdf_layer_override());
|
||||
Ok(Value::Array(
|
||||
designcraft_render::pdf_layers(&data)
|
||||
.into_iter()
|
||||
.map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on}))
|
||||
.map(|(name, on)| json!({"visible": if overridden { !hidden.contains(&name) } else { on }, "name": name, "default": on, "overridden": overridden}))
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
@@ -30,16 +32,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Layer Options…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)",
|
||||
"{ids?, hidden: [layer name], useDefaults?: bool} — hide layers; useDefaults:false makes [] explicitly all on; legacy [] restores file defaults",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let ids = targets(s, p)?;
|
||||
let ids: Vec<_> = targets(s, p)?.into_iter().filter(|id| placed_pdf(s, *id).is_some()).collect();
|
||||
let hidden: Vec<String> = p
|
||||
.get("hidden")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
|
||||
let use_defaults = match p.get("useDefaults") {
|
||||
None => hidden.is_empty(),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("object.layerOptions", "useDefaults must be a boolean"))?,
|
||||
};
|
||||
if use_defaults && !hidden.is_empty() {
|
||||
return Err(bad("object.layerOptions", "useDefaults cannot be combined with hidden layers"));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad("object.layerOptions", "select a placed PDF"));
|
||||
}
|
||||
// Validate every selected PDF before editing any item or recording undo.
|
||||
@@ -62,9 +71,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for id in &ids {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
it.pdf_hidden_layers = hidden.clone();
|
||||
it.pdf_layer_override = !use_defaults;
|
||||
}
|
||||
}
|
||||
Ok(json!({"hidden": hidden}))
|
||||
Ok(json!({"hidden": hidden, "useDefaults": use_defaults}))
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -378,4 +388,66 @@ pub(crate) mod tests {
|
||||
let r = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}");
|
||||
}
|
||||
#[test]
|
||||
fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() {
|
||||
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s
|
||||
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap();
|
||||
let pixel = |s: &Session| {
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
rr.threads = 0;
|
||||
rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150)
|
||||
};
|
||||
assert!(!visible(&mut s));
|
||||
assert!(pixel(&s)[1] > 200, "red hidden by default");
|
||||
s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap();
|
||||
assert!(visible(&mut s));
|
||||
assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!visible(&mut s));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(visible(&mut s));
|
||||
let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap();
|
||||
let roundtrip = designcraft_format::load(&bytes).unwrap();
|
||||
let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap();
|
||||
assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty());
|
||||
let exported = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap());
|
||||
let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap();
|
||||
let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap();
|
||||
assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}");
|
||||
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap());
|
||||
let imported = designcraft_idml::import_idml(&bytes).unwrap();
|
||||
let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap();
|
||||
assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on));
|
||||
assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf);
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap();
|
||||
assert!(!visible(&mut s), "legacy empty list still resets defaults");
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap();
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap();
|
||||
assert!(!visible(&mut s), "explicit reset restores defaults");
|
||||
}
|
||||
#[test]
|
||||
fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let placed = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100}),
|
||||
)
|
||||
.unwrap();
|
||||
let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap();
|
||||
s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override());
|
||||
assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err());
|
||||
assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ use serde_json::{Value, json};
|
||||
|
||||
pub const MIME: &str = "application/vnd.designcraft+zip";
|
||||
/// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults;
|
||||
/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer
|
||||
/// options. Version 4 is one capability shared with explicit all-on placed-PDF layer choices
|
||||
/// (storytold/designcraft#1671): it must not be released until a reader understands both, and any
|
||||
/// later capability needs version 5. [`required_version`] is the only place that picks a version.
|
||||
/// version 3 adds rendering of retained EPS source assets; version 4 is one capability covering both
|
||||
/// retained placed-graphic layer options and explicit all-on placed-PDF layer choices, so a version 4
|
||||
/// reader understands both. Any later capability needs version 5. [`required_version`] is the only
|
||||
/// place that picks a version.
|
||||
pub const VERSION: u32 = 4;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -73,8 +73,10 @@ const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20);
|
||||
/// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer
|
||||
/// keep their older requirement so existing readers still open them.
|
||||
fn required_version(doc: &Document) -> u32 {
|
||||
// Older readers ignore unknown Graphic fields and would silently drop layer choices.
|
||||
if doc.has_graphic_layer_options() {
|
||||
// Version 4 covers both of these: older readers ignore the unknown fields, so they would
|
||||
// silently drop retained graphic layer choices, or read an explicit all-on PDF layer choice
|
||||
// (empty hidden list) as "use the file's defaults". A nonempty hidden list stays readable.
|
||||
if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() {
|
||||
return 4;
|
||||
}
|
||||
// Older readers would silently omit EPS artwork. Preview-only legacy assets and documents
|
||||
@@ -454,6 +456,36 @@ mod tests {
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
let version = |d: &Document| {
|
||||
let bytes = save(d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
|
||||
};
|
||||
assert_eq!(version(&d), 2);
|
||||
d.item_mut(id).unwrap().pdf_layer_override = true;
|
||||
assert_eq!(version(&d), 4);
|
||||
assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override);
|
||||
d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
|
||||
+27
-16
@@ -654,13 +654,10 @@ impl Renderer {
|
||||
let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.push_clip_layer(bp);
|
||||
if !it.pdf_hidden_layers.is_empty() {
|
||||
HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone());
|
||||
}
|
||||
let hidden = it.has_pdf_layer_override().then(|| it.pdf_hidden_layers.clone());
|
||||
let previous = HIDDEN_LAYERS.with(|h| h.replace(hidden));
|
||||
self.draw_graphic(ctx, f, g, xf);
|
||||
if !it.pdf_hidden_layers.is_empty() {
|
||||
HIDDEN_LAYERS.with(|h| h.borrow_mut().clear());
|
||||
}
|
||||
HIDDEN_LAYERS.with(|h| h.replace(previous));
|
||||
ctx.pop_layer();
|
||||
}
|
||||
// Items pasted into the frame, clipped by it.
|
||||
@@ -880,12 +877,14 @@ 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) {
|
||||
// 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()
|
||||
let data = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) {
|
||||
// An explicit choice (including all-on) that cannot be rewritten fails closed:
|
||||
// stale persisted/relinked choices must not reveal hidden source content.
|
||||
Some(hidden) => {
|
||||
let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return };
|
||||
variant
|
||||
}
|
||||
None => 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]);
|
||||
@@ -1025,7 +1024,7 @@ fn rect_overlaps(a: Rect, b: Rect) -> bool {
|
||||
|
||||
thread_local! {
|
||||
/// Object Layer Options of the placed PDF being drawn.
|
||||
static HIDDEN_LAYERS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static HIDDEN_LAYERS: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
|
||||
/// Appearance of Black for the render on this thread: 100% K as pure black.
|
||||
static RICH_BLACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Separations Preview plate for the render on this thread.
|
||||
@@ -1296,11 +1295,23 @@ pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String],
|
||||
/// Rasterize a placed page for export, preserving available interpreter diagnostics.
|
||||
/// This cannot detect backend approximations that do not emit an interpreter warning.
|
||||
pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result<Vec<u8>, PdfRasterError> {
|
||||
pdf_page_png_options_checked(bytes, page, max_side, (!hidden.is_empty()).then_some(hidden), shown)
|
||||
}
|
||||
|
||||
/// Rasterize with an explicit visibility override: `Some(&[])` enables every layer;
|
||||
/// `None` retains file defaults. The legacy entry point keeps its empty-list semantics.
|
||||
pub fn pdf_page_png_options_checked(
|
||||
bytes: &[u8],
|
||||
page: usize,
|
||||
max_side: u32,
|
||||
hidden: Option<&[String]>,
|
||||
shown: Option<[f64; 4]>,
|
||||
) -> Result<Vec<u8>, PdfRasterError> {
|
||||
check_pdf_input_size(bytes.len())?;
|
||||
let data = if hidden.is_empty() {
|
||||
std::borrow::Cow::Borrowed(bytes)
|
||||
} else {
|
||||
let data = if let Some(hidden) = hidden {
|
||||
std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(bytes)
|
||||
};
|
||||
let raster = render_pdf_raster(&data, page, max_side)?;
|
||||
if raster.diagnostics.image_decode_failed {
|
||||
|
||||
@@ -2411,8 +2411,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
&& let Some(o) = l.as_object_mut()
|
||||
{
|
||||
o.insert("visible".into(), json!(on));
|
||||
d.fields.insert("useDefaults".into(), json!(false));
|
||||
}
|
||||
}
|
||||
if ui.button(crate::i18n::tr(&app.ui.language, "Use File Defaults")).clicked() {
|
||||
for layer in &mut layers {
|
||||
layer["visible"] = layer["default"].clone();
|
||||
}
|
||||
d.fields.insert("useDefaults".into(), json!(true));
|
||||
}
|
||||
d.fields.insert("layers".into(), json!(layers));
|
||||
}
|
||||
"colorSettings" => {
|
||||
@@ -3269,7 +3276,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
"layerOptions" => {
|
||||
let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default();
|
||||
app.run("object.layerOptions", json!({"hidden": hidden}))
|
||||
app.run("object.layerOptions", json!({"hidden": if d.b("useDefaults") { Vec::<Value>::new() } else { hidden }, "useDefaults": d.b("useDefaults")}))
|
||||
}
|
||||
"colorSettings" => {
|
||||
let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?;
|
||||
|
||||
@@ -1556,7 +1556,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
Ok(v) => v,
|
||||
Err(e) => return Some(Err(e.to_string())),
|
||||
};
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("layerOptions", json!({"layers": layers})));
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new(
|
||||
"layerOptions",
|
||||
json!({"useDefaults": !layers.as_array().and_then(|a| a.first()).and_then(|l| l["overridden"].as_bool()).unwrap_or(false), "layers": layers}),
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.rotateSpread" => {
|
||||
|
||||
@@ -207,6 +207,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
- **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.
|
||||
- **Layer choices:** placed PDFs distinguish explicit all-on visibility from file defaults;
|
||||
choices survive native saves, raster/PDF output and embedded IDML export. IDML layer
|
||||
variants are embedded copies; external-only export is rejected when choices would be lost.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Placed PDF layer visibility
|
||||
|
||||
Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed.
|
||||
|
||||
The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Queries include `overridden` separately from each layer's `visible` and `default` states.
|
||||
|
||||
Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice.
|
||||
|
||||
This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes are tracked separately. The concurrent graphic-layer metadata PR also changes native format capability; its version selection must be reconciled when merging both features.
|
||||
Reference in New Issue
Block a user