mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 14:47:05 -06:00
M6: leave placements nested in cells and notes out of the PDF layer pass
Both exports walk Document::placed_items: document and parent spreads, groups, and the objects anchored in a story. That is the walk the parent-page and anchored fix introduced. The nested-story traversal (table cells, notes, endnotes), Document::update_placed_items and the fail-closed refusals that went with it are not part of it: that is the traversal change on its own, not the placements this pass carries. update_items keeps its reach, so the two exports clear the choice where the placement actually is again. A choice anchored inside a table cell's or a note's text therefore stays on the document and is not carried into the embedded IDML copy, as on main; the refusal that turned that silent difference into an export error goes with the traversal it guarded, since asking the walk's own predicate it could never fire. The stale-field half keeps: has_applicable_pdf_layer_overrides still asks for a graphic whose asset is a PDF, so relinking a placed PDF to an image no longer makes the package report name a choice that does not exist. Tests: the nested-placement regression and the relink integration test of the removed refusal go; the parent-page regression, the stale-field predicate test and the format 4 cell-only reader test stay. The comments no longer claim every placement counts.
This commit is contained in:
@@ -672,8 +672,9 @@ 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.
|
||||
///
|
||||
/// Every placement counts, including parent-page and anchored ones, so a choice is never
|
||||
/// silently replaced by the file's defaults.
|
||||
/// Parent-page and anchored placements count too, so a page that inherits the placement is not
|
||||
/// silently replaced by the file's defaults. A choice anchored inside a table cell's or a note's
|
||||
/// text keeps the source file's defaults (see `docs/pdf-layer-visibility.md`).
|
||||
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
|
||||
let choices: Vec<_> = d
|
||||
.placed_items()
|
||||
@@ -686,12 +687,6 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraf
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
// Fail closed, with the same predicate as the walk. As in the IDML pass this is a tripwire
|
||||
// for a future construct the walk misses, not live behaviour; a stale choice on an item that
|
||||
// is no longer a placed PDF must not block the export.
|
||||
if super::interchange::has_applicable_pdf_layer_overrides(d) {
|
||||
return Err(EngineError::Other("a placed PDF's layer choice sits where this raster export cannot apply it".into()));
|
||||
}
|
||||
return Ok((None, 0));
|
||||
}
|
||||
let mut out = d.clone();
|
||||
@@ -721,7 +716,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraf
|
||||
images.insert(id, aid);
|
||||
n += 1;
|
||||
}
|
||||
out.update_placed_items(
|
||||
out.update_items(
|
||||
|it| images.contains_key(&it.id),
|
||||
|it| {
|
||||
if let designcraft_doc::Content::Graphic(gg) = &mut it.content
|
||||
|
||||
@@ -40,9 +40,9 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a placement still holds an Object Layer Options choice the exports can apply: a
|
||||
/// graphic whose asset is a PDF. A choice left on an item that is no longer a placed PDF (its
|
||||
/// link was replaced with another format) is not one, and must not block an export.
|
||||
/// Whether a placement holds an Object Layer Options choice an export can still apply: a graphic
|
||||
/// whose asset is a PDF. A choice left on an item that is no longer a placed PDF (its link was
|
||||
/// replaced with another format) is not one, so the package report does not name it.
|
||||
pub(crate) fn has_applicable_pdf_layer_overrides(doc: &Document) -> bool {
|
||||
doc.placed_items().into_iter().any(|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))
|
||||
@@ -52,8 +52,10 @@ pub(crate) fn has_applicable_pdf_layer_overrides(doc: &Document) -> bool {
|
||||
/// 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.
|
||||
///
|
||||
/// Every placement counts, including parent-page and anchored ones: a choice that the copy
|
||||
/// cannot carry is rejected rather than exported as the file's defaults.
|
||||
/// Parent-page and anchored placements count too, so a page that inherits the placement does not
|
||||
/// quietly fall back to the file's defaults. A choice anchored inside a table cell's or a note's
|
||||
/// text is not carried yet: this walk does not follow the stories nested there (see
|
||||
/// `docs/pdf-layer-visibility.md`).
|
||||
fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Document>> {
|
||||
let choices: Vec<_> = doc
|
||||
.placed_items()
|
||||
@@ -65,14 +67,6 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Documen
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
// A tripwire, not live behaviour: the walk and this predicate cover the same placements, so
|
||||
// they can only disagree if a future nesting construct slips past the walk, and that would
|
||||
// silently become the file's defaults. Do not narrow the walk to match this check. The
|
||||
// predicate asks for a graphic whose asset is a PDF, like the walk, so stale fields left on a
|
||||
// relinked image never block an export.
|
||||
if has_applicable_pdf_layer_overrides(doc) {
|
||||
return Err(EngineError::Other("a placed PDF's layer choice sits where this IDML export cannot apply it".into()));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if !embed {
|
||||
@@ -93,7 +87,7 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Documen
|
||||
}
|
||||
// Anchored objects live in stories, and parent pages outside the document-spread walk, so
|
||||
// the choice is cleared where the placement actually is.
|
||||
out.update_placed_items(
|
||||
out.update_items(
|
||||
|it| variants.contains_key(&it.id),
|
||||
|it| {
|
||||
if let designcraft_doc::Content::Graphic(g) = &mut it.content
|
||||
|
||||
@@ -507,93 +507,16 @@ pub(crate) mod tests {
|
||||
// An external-only copy would keep the original file's defaults: the choice is rejected.
|
||||
let err = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string();
|
||||
assert!(err.contains("embedded images"), "{err}");
|
||||
// PDF/A-2b rasterises a placement with a choice wherever it sits, warnings included.
|
||||
// PDF/A-2b rasterises a parent-page placement with a choice, warnings included.
|
||||
let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}");
|
||||
}
|
||||
|
||||
/// A choice on a frame anchored inside a table cell or a footnote is a placement too: the
|
||||
/// save asks for the newer reader, the IDML copy gets a variant, an external-only copy is
|
||||
/// refused and PDF/A-2b rasterises it, rather than each quietly falling back to the file.
|
||||
#[test]
|
||||
fn nested_placements_keep_their_layer_choices_in_saves_and_exports() {
|
||||
use designcraft_doc::{AnchoredObject, Content, Graphic, Item, Shape, SpreadRef, Table};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
for in_note in [false, true] {
|
||||
let mut d = designcraft_doc::Document::new(&Default::default());
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
d.add_asset(designcraft_doc::Asset {
|
||||
id: asset,
|
||||
name: "nested.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: std::sync::Arc::new(pdf.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
let mut item = Item::new(
|
||||
designcraft_doc::ItemId(d.alloc()),
|
||||
d.default_layer(),
|
||||
Shape::Rectangle,
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
);
|
||||
item.pdf_layer_override = true;
|
||||
item.content = Content::Graphic(Graphic {
|
||||
asset,
|
||||
size: (100.0, 100.0),
|
||||
xf: Affine::IDENTITY,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
layer_options: None,
|
||||
});
|
||||
let layer = d.default_layer();
|
||||
let (_, story) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
|
||||
let anchored = AnchoredObject { item, position: Default::default() };
|
||||
if in_note {
|
||||
let st = d.story_mut(story).unwrap();
|
||||
st.insert_note(0, "Note", Default::default()).unwrap();
|
||||
assert_eq!(st.notes.len(), 1);
|
||||
std::sync::Arc::make_mut(&mut st.notes[0]).text.insert_object(0, anchored);
|
||||
} else {
|
||||
let mut table = Table::new(1, 1, 1, 0, 0, 20.0);
|
||||
table.cells[0].text.insert_object(0, anchored);
|
||||
d.story_mut(story).unwrap().insert_table(0, table);
|
||||
}
|
||||
assert!(d.has_pdf_layer_overrides(), "the nested placement carries the choice");
|
||||
let mut s = Session::new();
|
||||
s.add_document(crate::DocState::new(d, None));
|
||||
// The save keeps it under the newer reader, as the detection walk says.
|
||||
let saved = designcraft_format::save(&s.doc().unwrap().doc).unwrap();
|
||||
let reopened = designcraft_format::load(&saved).unwrap();
|
||||
assert!(reopened.has_pdf_layer_overrides(), "in_note={in_note}");
|
||||
s.add_document(crate::DocState::new(reopened, None));
|
||||
// Embedded IDML carries a variant that shows every layer.
|
||||
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap();
|
||||
let copies: Vec<_> = imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).collect();
|
||||
assert!(!copies.is_empty(), "in_note={in_note}: the anchored placement is exported");
|
||||
assert!(
|
||||
copies.iter().all(|a| designcraft_render::pdf_layers(&a.data).iter().all(|(_, on)| *on)),
|
||||
"in_note={in_note}: the embedded copy must not fall back to the file's defaults"
|
||||
);
|
||||
// An external-only copy would keep the original file's defaults.
|
||||
assert!(s.execute("file.exportIdml", &json!({"embedImages": false})).is_err(), "in_note={in_note}");
|
||||
// PDF/A-2b rasterises a placement with a choice, wherever it sits.
|
||||
let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"}));
|
||||
assert!(a2b.is_ok(), "in_note={in_note}: {a2b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a
|
||||
/// placed-PDF choice, so they must not block an export: the refusal guard asks for a graphic whose
|
||||
/// asset is a PDF, the same thing the export walks ask for. The user path (a real relink to a PNG)
|
||||
/// is covered in `tests/pdf_optional_content.rs`, where the file guard allows a test fixture on disk.
|
||||
/// placed-PDF choice: the package report asks for a graphic whose asset is a PDF, the same thing
|
||||
/// the export walks ask for, so it does not name the stale field, and exports run unaffected.
|
||||
#[test]
|
||||
fn a_stale_choice_on_a_non_pdf_placement_does_not_block_exports() {
|
||||
fn a_stale_choice_on_a_non_pdf_placement_is_not_a_placed_pdf_choice() {
|
||||
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
@@ -608,7 +531,7 @@ pub(crate) mod tests {
|
||||
assert!(d.item(item).unwrap().has_pdf_layer_override(), "the stale field is on the item");
|
||||
assert!(d.has_pdf_layer_overrides(), "the field is there");
|
||||
assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice");
|
||||
// Both exports still run: nothing is dropped, and there is no choice to refuse.
|
||||
// Nothing is dropped: the field is not a choice any export applies.
|
||||
s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user