M6: preserve per-placement graphic layer options

This commit is contained in:
andeecollard
2026-10-24 13:22:08 +01:00
committed by Andee Collard
parent c313c53497
commit 7074dc8a85
34 changed files with 363 additions and 5 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
# DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
+1
View File
@@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result<Document, String> {
let (nw, nh) = (aw as f64, ah as f64);
let s = (r.width() / nw).max(r.height() / nh);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s),
+3
View File
@@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() {
);
let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (150.0, 200.0),
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
@@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() {
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame);
// The image overhangs the frame on every side.
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(d.alloc()),
size: (300.0, 250.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() {
true,
));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (150.0, 200.0),
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
+32
View File
@@ -15,6 +15,37 @@ use crate::{Asset, AssetId, Content, Document, Item};
/// is treated as using every asset, so nothing it references is merged away or pruned.
const MAX_DEPTH: usize = 64;
fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool {
depth > MAX_DEPTH
|| it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|| it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only))
}
fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool {
depth > MAX_DEPTH
|| st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only))
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| {
cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|| story_layer_options(&cell.text, depth + 1, overrides_only)
})
|| st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(&note.text, depth + 1, overrides_only))
}
impl Document {
/// Whether saving needs a reader that retains per-placement graphic layer settings.
/// Exceeding the traversal budget conservatively requires the newer format.
pub fn has_graphic_layer_options(&self) -> bool {
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false))
|| self.stories.values().any(|story| story_layer_options(story, 0, false))
}
/// Includes parent items, anchored objects, graphic cells and nested notes.
pub fn has_graphic_layer_visibility_overrides(&self) -> bool {
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
}
}
impl Asset {
/// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at
/// different paths stay apart even with identical bytes, since each can be updated or
@@ -356,6 +387,7 @@ mod tests {
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: AssetId(asset),
size: (10.0, 10.0),
xf: Affine::translate((crop, 0.0)),
+26
View File
@@ -596,10 +596,34 @@ pub enum Fitting {
CenterContent,
}
/// Retained IDML layer choices for one placement, independent of shared asset bytes.
/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy.
/// XML Self IDs are regenerated by the exporter and are not stored here.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GraphicLayerOptions {
pub attributes: std::collections::BTreeMap<String, String>,
pub layers: Vec<std::collections::BTreeMap<String, String>>,
}
pub const GRAPHIC_LAYER_RENDERING_WARNING: &str =
"Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output";
impl GraphicLayerOptions {
pub fn has_visibility_overrides(&self) -> bool {
self.layers.iter().any(|layer| {
layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility"))
|| ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true"))
})
}
}
/// A placed graphic inside a frame.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_options: Option<GraphicLayerOptions>,
pub asset: AssetId,
/// Natural size of the graphic in points (pixels at its resolution, or PDF page size).
pub size: (f64, f64),
@@ -1642,6 +1666,7 @@ mod tests {
#[test]
fn graphics_without_a_clipping_path_load_and_save_as_before() {
let g = Graphic {
layer_options: None,
asset: AssetId(3),
size: (40.0, 30.0),
xf: Affine::translate((5.0, 5.0)),
@@ -1653,6 +1678,7 @@ mod tests {
fill: Default::default(),
};
let v = serde_json::to_value(&g).unwrap();
assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation");
assert!(v.get("clip").is_none(), "{v}");
assert_eq!(serde_json::from_value::<Graphic>(v.clone()).unwrap(), g);
// A clip written with only its type and path gets the default options.
+1
View File
@@ -369,6 +369,7 @@ fn place_image(
};
let mode = fitting_of(&options.fitting);
let mut g = Graphic {
layer_options: None,
asset: AssetId(0),
size: (nw, nh),
xf: Affine::translate((inner.x0, inner.y0)),
+2
View File
@@ -434,6 +434,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp
);
let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w as f64, h as f64),
xf: designcraft_geom::Affine::translate((r.x0, r.y0))
@@ -604,6 +605,7 @@ fn rasterize_where(
);
let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach));
img_item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w as f64, h as f64),
xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k),
+3
View File
@@ -710,6 +710,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k);
validate_placement((bw, bh), r, xf, "file.place")?;
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (bw, bh),
xf,
@@ -736,6 +737,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
it.effects = e.clone();
}
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (bw, bh),
xf,
@@ -900,6 +902,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
validate_placement((nw, nh), frame_rect, xf, "place.drop")?;
let r = s.edit(|d, sel| {
let graphic = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (nw, nh),
xf,
+3
View File
@@ -1128,6 +1128,7 @@ mod shared_asset_tests {
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: sid,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -1173,6 +1174,7 @@ mod shared_asset_tests {
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: missing,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -1209,6 +1211,7 @@ mod shared_asset_tests {
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: missing,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
+1
View File
@@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w, h),
xf: Affine::translate(at),
+2
View File
@@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
let r = it.inner_bounds();
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (r.width(), r.height()),
xf: Affine::translate((r.x0, r.y0)),
@@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?;
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w, h),
xf: Affine::translate((x, y)),
+22
View File
@@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
let xf = parent * it.xf;
pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf)));
let page = page_of(d, SpreadRef::Doc(si), it, parent);
if let Content::Graphic(g) = &it.content
&& g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides)
{
out.push(Issue {
severity: "warning",
kind: "unsupportedGraphicLayers",
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
item: Some(it.id.0),
page,
});
}
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }),
@@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
}
}
}
// Nested graphic cells and anchored/parent items may not have a page frame
// in the walk above. Keep the warning visible after a native save/reopen.
if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() {
out.push(Issue {
severity: "warning",
kind: "unsupportedGraphicLayers",
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
item: None,
page: None,
});
}
out
}
+1
View File
@@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?;
cell.graphic = Some(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (nw, nh),
xf,
+1
View File
@@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: (
let (nw, nh) = (px.0 as f64, px.1 as f64);
let k = (r.width() / nw).max(r.height() / nh);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
@@ -0,0 +1,82 @@
//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported.
use std::io::{Cursor, Read, Write};
use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING};
use designcraft_engine::{DocState, Session};
use serde_json::json;
fn fixture() -> Vec<u8> {
let xml = r#"<Document><Layer Self="L" Name="Synthetic"/><Spread Self="sp"><Page Self="p" GeometricBounds="0 0 100 100"/>
<Rectangle Self="r" ItemLayer="L" ContentType="GraphicType"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 20"/><PathPointType Anchor="20 20"/><PathPointType Anchor="20 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties><Image Self="im">
<Properties><GraphicBounds Left="0" Top="0" Right="20" Bottom="20"/></Properties>
<Link Self="lk" LinkResourceURI="file:/definitely-missing/synthetic.psd"/>
<GraphicLayerOption UpdateLinkOption="KeepOverrides">
<GraphicLayer Self="g1" Id="1" Name="Hidden" OriginalVisibility="true" CurrentVisibility="false"/>
<GraphicLayer Self="g2" Id="2" Name="Shown" OriginalVisibility="false" CurrentVisibility="true"/>
</GraphicLayerOption></Image></Rectangle></Spread></Document>"#;
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(xml.as_bytes()).unwrap();
z.finish().unwrap().into_inner()
}
#[test]
fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() {
let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap();
assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING)));
let doc = imported.document;
let options = |d: &designcraft_doc::Document| {
d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => g.layer_options.clone(),
_ => None,
})
.collect::<Vec<_>>()
};
let expected = options(&doc);
let saved = designcraft_format::save(&doc).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap();
let mut meta = String::new();
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings");
let reopened = designcraft_format::load(&saved).unwrap();
assert_eq!(options(&reopened), expected);
let mut s = Session::new();
s.add_document(DocState::new(reopened, None));
let preflight = s.execute("preflight.run", &json!({})).unwrap();
assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers"));
let pdf = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING));
}
#[test]
fn graphic_layer_options_in_cells_require_the_new_native_reader() {
let mut doc = designcraft_idml::import_idml(&fixture()).unwrap();
let graphic = doc.spreads[0]
.items
.iter()
.find_map(|i| match &i.content {
Content::Graphic(g) => Some(g.clone()),
_ => None,
})
.unwrap();
std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear();
let layer = doc.default_layer();
let (_, story) = doc
.add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default())
.unwrap();
let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0);
table.cells[0].graphic = Some(graphic);
doc.story_mut(story).unwrap().insert_table(0, table);
assert!(doc.has_graphic_layer_options());
let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap();
assert!(reopened.has_graphic_layer_visibility_overrides());
let mut s = Session::new();
s.add_document(DocState::new(reopened, None));
let preflight = s.execute("preflight.run", &json!({})).unwrap();
assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers"));
}
+3
View File
@@ -1531,6 +1531,7 @@ mod tests {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
@@ -1939,6 +1940,7 @@ mod tests {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
@@ -2055,6 +2057,7 @@ mod tests {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
item.alt_text = "Cover \"A\" & B".into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
@@ -100,6 +100,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() {
let image = ItemId(doc.alloc());
let mut item = Item::new(image, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 10.0)));
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
+11 -3
View File
@@ -22,8 +22,8 @@ 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.
pub const VERSION: u32 = 3;
/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options.
pub const VERSION: u32 = 4;
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
@@ -87,7 +87,14 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
// Older readers would silently omit EPS artwork. Preview-only legacy assets and
// documents without EPS stay at 2 so their existing readers keep working; writing 1
// would make a reader re-run the version 2 paragraph upgrade over current defaults.
let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 };
// Older readers ignore unknown Graphic fields and would silently drop layer choices.
let version = if doc.has_graphic_layer_options() {
4
} else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) {
3
} else {
2
};
let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))});
z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?;
if let Some(png) = preview {
@@ -404,6 +411,7 @@ mod tests {
let id = ItemId(d.alloc());
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (1.0, 1.0),
xf: Affine::translate((crop, crop)),
+26
View File
@@ -20,6 +20,14 @@ const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents";
const TOC_STYLE_NAME: &str = "Table of Contents";
const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]";
// Retained IDML attributes have unprefixed ASCII names. Native JSON can be
// edited independently: never let its map keys become XML syntax or Self IDs.
fn graphic_layer_attribute_name(key: &str) -> bool {
!key.is_empty()
&& key != "Self"
&& key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-'))))
}
/// Export options.
#[derive(Clone, Debug)]
pub struct ExportOptions {
@@ -2061,6 +2069,24 @@ impl<'a> Ex<'a> {
if let Some(c) = &g.clip {
el.push(clipping_settings(c));
}
if let Some(options) = &g.layer_options {
let mut option = El::new("GraphicLayerOption");
for (key, value) in &options.attributes {
if graphic_layer_attribute_name(key) {
option.set(key, value);
}
}
for attributes in &options.layers {
let mut layer = El::new("GraphicLayer").attr("Self", self.fresh());
for (key, value) in attributes {
if graphic_layer_attribute_name(key) {
layer.set(key, value);
}
}
option.push(layer);
}
el.push(option);
}
let uri = match &asset.link {
Some(l) => path_to_uri(l),
None => format!("file:{}", asset.name),
+8
View File
@@ -3043,6 +3043,13 @@ impl<'r> Importer<'r> {
}
fn graphic(&mut self, g: &El) -> Content {
let layer_options = g.find("GraphicLayerOption").map(|options| {
let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect();
designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() }
});
if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) {
self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING));
}
let gxf = parse_affine(g.get("ItemTransform"));
let (l, t, r, b) = match g.prop_el("GraphicBounds") {
Some(gb) => {
@@ -3059,6 +3066,7 @@ impl<'r> Importer<'r> {
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
let graphic = |asset, size| {
Content::Graphic(designcraft_doc::Graphic {
layer_options: layer_options.clone(),
asset,
size,
xf: gxf * Affine::translate((l, t)),
+1
View File
@@ -4453,6 +4453,7 @@ fn exports_graphic_in_table_cell() {
});
let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
layer_options: None,
asset: id,
size: (24.0, 24.0),
xf: designcraft_geom::Affine::IDENTITY,
+71
View File
@@ -70,6 +70,77 @@ fn package(frames: &[String]) -> Vec<u8> {
])
}
#[test]
fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() {
let option = |n: usize, visible: &str| {
format!(
r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides"><GraphicLayer Self="gl{n}" Id="7" Name="Synthetic layer" OriginalVisibility="true" CurrentVisibility="{visible}" Locked="true" HasPrintState="true" PrintState="false"/></GraphicLayerOption>"#
)
};
let frames = ["false", "true"]
.into_iter()
.enumerate()
.map(|(i, visible)| {
let n = i + 1;
frame(n, i as f64 * 100.0, 0.0, &image(n, PHOTO, None).replace("</Image>", &format!("{}</Image>", option(n, visible))))
})
.collect::<Vec<_>>();
let reads = Cell::new(0);
let loaded = import_idml_report_with_previews(
&package(&frames),
&|_| {
reads.set(reads.get() + 1);
Some(vec![42; 16])
},
&Default::default(),
)
.unwrap();
assert_eq!(reads.get(), 1);
assert_eq!(loaded.document.assets.len(), 1, "layer choices belong to placements, not shared image bytes");
let out = export_idml(&loaded.document);
let spreads = zip_text(&out, "Spreads/");
assert_eq!(spreads.matches("<GraphicLayer ").count(), 2, "both placements must retain their independent layer choices");
assert!(spreads.contains("CurrentVisibility=\"false\""));
assert!(spreads.contains("CurrentVisibility=\"true\""));
assert_eq!(spreads.matches("UpdateLinkOption=\"KeepOverrides\"").count(), 2);
assert_eq!(spreads.matches("PrintState=\"false\"").count(), 2);
assert!(loaded.warnings.iter().any(|w| w.contains("layer visibility")));
let reopened = import_idml(&out).unwrap();
let options = |d: &Document| {
d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => g.layer_options.clone(),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(options(&loaded.document), options(&reopened));
let twice = zip_text(&export_idml(&reopened), "Spreads/");
assert_eq!(twice.matches("<GraphicLayer ").count(), 2);
}
#[test]
fn graphic_layer_attributes_from_native_data_cannot_inject_xml_or_self_ids() {
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
panic!("graphic")
};
let attributes = std::collections::BTreeMap::from([
("Name".into(), "A < B & C".into()),
("Self".into(), "injected-id".into()),
("bad\" injected=\"true".into(), "invalid".into()),
]);
g.layer_options = Some(designcraft_doc::GraphicLayerOptions { attributes: attributes.clone(), layers: vec![attributes] });
let out = export_idml(&d);
let xml = zip_text(&out, "Spreads/");
assert!(!xml.contains("injected-id") && !xml.contains("injected="));
let back = import_idml(&out).unwrap();
let Content::Graphic(g) = &back.spreads[0].items[0].content else { panic!("graphic") };
assert_eq!(g.layer_options.as_ref().unwrap().layers[0]["Name"], "A < B & C");
}
/// (asset, crop, transform) of each graphic, left to right.
fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> {
let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0]
+4
View File
@@ -1381,6 +1381,9 @@ impl Exporter<'_> {
/// Draw a placed graphic, inside its clipping path when it has one.
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
if g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) {
self.warn(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING);
}
match g.clip_region().map(|(region, even_odd)| (to_path(&region), even_odd)) {
Some((Some(clip), even_odd)) => {
s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero });
@@ -2175,6 +2178,7 @@ mod eps_tests {
designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)),
);
item.content = Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (2.0, 1.0),
xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0),
+1
View File
@@ -496,6 +496,7 @@ mod tests {
let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0);
let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size,
xf: Affine::translate(at) * Affine::scale(100.0 / size.0),
+1
View File
@@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset,
size: (px.0 as f64, px.1 as f64),
xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0),
@@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec<u8>, x: f64, name: &str, mime: &str,
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0)));
item.stroke.weight = 0.0;
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (20.0, 20.0),
xf: Affine::translate((x, 36.0)),
+1
View File
@@ -212,6 +212,7 @@ fn pictures_are_cropped_to_their_frames() {
it.fill = Fill::none();
// 200 × 100 image at 1:1, its left quarter cut off by the frame.
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (200.0, 100.0),
xf: Affine::translate((50.0, 100.0)),
+2
View File
@@ -1984,6 +1984,7 @@ mod tests {
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (100.0, 100.0),
xf: Affine::translate((100.0, 100.0)),
@@ -2038,6 +2039,7 @@ mod tests {
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (100.0, 100.0),
xf: Affine::translate((100.0, 100.0)),
+1
View File
@@ -51,6 +51,7 @@ fn shown(file: Vec<u8>, fill: Fill) -> Vec<[u8; 4]> {
it.fill = Fill::swatch(CYAN);
it.stroke = Stroke::default();
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (150.0, 50.0),
xf: Affine::translate((100.0, 100.0)),
+1
View File
@@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec<u8>) -> Rendered {
let id = ItemId(d.alloc());
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size)));
item.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (size, size),
xf: Affine::IDENTITY,
+4
View File
@@ -27,6 +27,7 @@ fn color_theme_tool_requests_a_palette_without_editing_the_image() {
let mut d = Document::new(&NewDocument::default());
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -1108,6 +1109,7 @@ fn graphic_frame(doc: &mut Document) -> ItemId {
let id = ItemId(doc.alloc());
let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (200.0, 100.0),
xf: designcraft_geom::Affine::translate((50.0, 100.0)),
@@ -1502,6 +1504,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
let photo = ItemId(d.alloc());
let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
@@ -1855,6 +1858,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() {
let raster = {
let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
+1
View File
@@ -2943,6 +2943,7 @@ mod tests {
}),
);
doc.item_mut(id).unwrap().content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (72.0, 72.0),
xf: designcraft_geom::Affine::IDENTITY,
+3 -1
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -202,6 +202,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
- **Estimate:** 20–35 h.
### 19. Placed graphics depth (FF)
- **Have:** per-placement IDML graphic-layer settings survive IDML/native round trips; native format 4 guards older readers. Unsupported layer visibility is reported by import, preflight and PDF export. See [graphic-layer-options.md](graphic-layer-options.md).
- **Still missing:** applying the retained layer visibility to the rendered/PDF appearance; layer-comp choices and the layer-options UI. Metadata preservation does not establish visual fidelity.
- **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.
+40
View File
@@ -0,0 +1,40 @@
# Placed graphic layer options
IDML `GraphicLayerOption` / `GraphicLayer` settings are retained on each
`Graphic.layer_options`, not on the shared asset. Two placements of one linked
file can therefore keep different layer choices while sharing image bytes.
Scalar option/layer attributes, layer order, layer IDs/names, original/current
visibility, link-update policy and view/export/print flags survive import,
native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited
attribute maps cannot inject XML syntax or override those Self IDs.
This is preservation, **not layer-aware rendering**. The current renderer and
PDF exporter use the asset's composite/default appearance and do not apply
these per-placement choices. Changed visibility and explicit view/export/print
states produce an import warning, a persistent `unsupportedGraphicLayers`
preflight issue, and a PDF export warning. Nested graphic cells, anchored
objects and parent items remain detectable after native reopening.
Native files containing these settings require format version 4. Earlier
readers reject that version instead of loading and silently discarding the new
field. Documents without layer settings retain their existing version 2/3
requirements. Older graphics without `layerOptions` still deserialize with
`None`, and their JSON representation is unchanged.
Synthetic regressions cover independent overrides on two placements sharing
one asset, metadata round trips, native reader requirements, nested cells,
preflight/PDF warnings and invalid native attribute names. Private originals
and artwork are not repository fixtures; any collection results are local
metadata checks, not an independent reference-rendering or fidelity oracle.
Local collection check (2026-10-23): a five-document pilot passed before
expansion to 205 previously converted documents. All 205 completed IDML →
native save/reopen → IDML; all 1,113 layer records across 68 documents matched
their input scalar attributes and order, ignoring regenerated Self IDs.
Persistent preflight warnings appeared in the 57 documents with overrides.
The private source files, layer names, document paths and per-file logs remain
outside this repository. Original INDD files were not opened by this check.
Remaining work: apply visibility to PSD layers/PDF optional content, implement
layer-comp choices and a layer-options UI, and validate appearance against
verified same-save reference PDFs. Gap 19 remains open.