mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
M6: preserve per-placement graphic layer options
This commit is contained in:
committed by
Andee Collard
parent
c313c53497
commit
7074dc8a85
+2
-1
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(¬e.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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(®ion), 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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user