IDML872.1: preserve liquid guides through interchange

Keep GuideType=Liquid on document and parent pages, with literal XML and
mixed-guide regression coverage. Leave object liquid constraints separate.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Keyne PEKAR
2026-10-15 17:13:35 +08:00
co-authored by Codex
parent a930276fd7
commit e569381c2e
7 changed files with 149 additions and 6 deletions
+2
View File
@@ -347,6 +347,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-15** · IDML: liquid guides keep their type on document and parent pages instead of reopening as ordinary ruler guides. Positions, orientation, Fit to Page, locks and layers are preserved; object liquid constraints remain unmapped (#872). No readiness percentage change.
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change.
+1 -1
View File
@@ -1686,7 +1686,7 @@ impl<'a> Ex<'a> {
.attr("ViewThreshold", "5")
.attr("Locked", bool_s(g.locked))
.attr("PageIndex", (first_page + i).to_string())
.attr("GuideType", "Ruler");
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
if let Some(l) = g.layer {
ge.set("ItemLayer", uid(l.0));
}
+1 -1
View File
@@ -2120,7 +2120,7 @@ impl<'r> Importer<'r> {
spread: g.get("FitToPage") == Some("false"),
locked: g.get("Locked") == Some("true"),
layer: g.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied(),
liquid: false,
liquid: g.get("GuideType") == Some("Liquid"),
})
})
.collect()
+3 -3
View File
@@ -22,13 +22,13 @@
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
//! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow,
//! feather), images (embedded `Contents` or linked), stories with paragraph/character style ranges and local overrides, special
//! characters and breaks, and text threading.
//! characters and breaks, text threading, ruler and liquid guides, and stored page liquid rules.
//!
//! ## TODO (ignored on import / not written on export)
//! - Tables, footnotes, anchored/inline objects, notes, tracked changes, hyperlinks, index and
//! cross-reference markers, text variables, conditions, XML structure (`XML/*` parts).
//! - Guides, page-item overrides of parent items (only the page's `OverrideList` is read),
//! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid layout.
//! - Page-item overrides of parent items (only the page's `OverrideList` is read),
//! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid object constraints.
//! - Nested/GREP/line styles, bullets & numbering details (bullet glyph, number format), OpenType
//! feature flags, paragraph borders, grid alignment, custom dashed stroke styles; column rule
//! stroke type and overprint.
+1
View File
@@ -10,6 +10,7 @@ use super::*;
mod assets;
mod decorations;
mod index_sort;
mod liquid_guides;
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
use zip::write::SimpleFileOptions;
+133
View File
@@ -0,0 +1,133 @@
//! Literal guide types must survive independently of the stored page layout rule (#872).
use super::*;
use designcraft_doc::{LiquidRule, Orientation};
use std::sync::Arc;
fn fixture(kind: Option<&str>, origin: &str) -> Vec<u8> {
let attribute = kind.map(|k| format!(r#" GuideType="{k}""#)).unwrap_or_default();
let map = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<idPkg:Preferences src="Resources/Preferences.xml"/>
<Layer Self="L1" Name="Guides" Visible="true" Locked="false"/>
<idPkg:MasterSpread src="MasterSpreads/A.xml"/>
<idPkg:Spread src="Spreads/S.xml"/>
</Document>"#;
let prefs = format!(
r#"<Preferences><DocumentPreference PageWidth="500" PageHeight="700" FacingPages="true"/>
<ViewPreference RulerOrigin="{origin}"/></Preferences>"#
);
let spread = |tag: &str, id: &str| {
let pages: String = [("left", -500), ("right", 0)]
.into_iter()
.map(|(side, x)| {
format!(
r#"<Page Self="{id}-{side}" LayoutRule="GuideBased" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 {x} -350">
<Guide Self="{id}-{side}-v" Orientation="Vertical" Location="72" FitToPage="true" Locked="true" ItemLayer="L1"{attribute}/>
<Guide Self="{id}-{side}-h" Orientation="Horizontal" Location="90" FitToPage="false" Locked="false" ItemLayer="L1" GuideType="Ruler"/>
</Page>"#
)
})
.collect();
format!(
r#"<idPkg:{tag} xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<{tag} Self="{id}" NamePrefix="A" BaseName="Parent" PageCount="2">{pages}</{tag}>
</idPkg:{tag}>"#
)
};
zip_files(&[
("designmap.xml", map),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/A.xml", &spread("MasterSpread", "m")),
("Spreads/S.xml", &spread("Spread", "s")),
])
}
#[test]
fn liquid_guides_import_literal_types_without_changing_other_guide_fields() {
for origin in ["SpreadOrigin", "PageOrigin", "SpineOrigin"] {
let baseline = import_idml(&fixture(None, origin)).unwrap();
for (kind, liquid) in [(Some("Liquid"), true), (Some("Ruler"), false), (None, false), (Some("liquid"), false), (Some("Unknown"), false)] {
let actual = import_idml(&fixture(kind, origin)).unwrap();
let mut expected = baseline.clone();
expected.created = actual.created;
for spread in expected.spreads.iter_mut().chain(&mut expected.parents) {
for page in &mut Arc::make_mut(spread).pages {
assert_eq!(page.guides.len(), 2);
page.guides[0].liquid = liquid;
}
}
assert_eq!(actual, expected, "{origin}, {kind:?}: only guide type may change");
actual.check().unwrap();
for spread in actual.spreads.iter().chain(&actual.parents) {
assert_eq!(spread.pages.len(), 2);
for (index, page) in spread.pages.iter().enumerate() {
assert_eq!(page.liquid, LiquidRule::GuideBased);
let [vertical, horizontal] = page.guides.as_slice() else { panic!("two guides expected") };
let x = match origin {
"PageOrigin" => 72.0 + index as f64 * 500.0,
"SpineOrigin" => 572.0,
_ => 72.0,
};
assert_eq!((vertical.orientation, vertical.position, vertical.spread, vertical.locked), (Orientation::Vertical, x, false, true));
assert_eq!(
(horizontal.orientation, horizontal.position, horizontal.spread, horizontal.locked),
(Orientation::Horizontal, 90.0, true, false)
);
assert!(!horizontal.liquid);
assert_eq!(actual.layer(vertical.layer.unwrap()).unwrap().name, "Guides");
}
}
}
}
}
#[test]
fn liquid_guides_export_literal_types_and_round_trip_on_pages_and_parents() {
use std::io::Read;
let mut source = import_idml(&fixture(None, "PageOrigin")).unwrap();
// Build the mixed native flags directly, without relying on the liquid-guide importer.
for spread in source.spreads.iter_mut().chain(&mut source.parents) {
for (index, page) in Arc::make_mut(spread).pages.iter_mut().enumerate() {
page.guides[index].liquid = true;
if index == 1 {
page.liquid = LiquidRule::Off;
}
}
}
source.check().unwrap();
let bytes = export_idml(&source);
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
for (directory, tag, spread) in [("Spreads", "Spread", &source.spreads[0]), ("MasterSpreads", "MasterSpread", &source.parents[0])] {
let path = format!("{directory}/{tag}_u{:x}.xml", spread.id.0);
let mut part = Vec::new();
archive.by_name(&path).unwrap().read_to_end(&mut part).unwrap();
let root = xml::parse(&part).unwrap();
let pages: Vec<_> = root.find(tag).unwrap().find_all("Page").collect();
assert_eq!(pages.len(), spread.pages.len());
for (xml_page, page) in pages.iter().zip(&spread.pages) {
let guides: Vec<_> = xml_page.find_all("Guide").collect();
assert_eq!(guides.len(), page.guides.len());
for (guide, expected) in guides.iter().zip(&page.guides) {
assert_eq!(guide.get("GuideType"), Some(if expected.liquid { "Liquid" } else { "Ruler" }), "{path}");
}
}
}
let actual = import_idml(&bytes).unwrap();
actual.check().unwrap();
assert_eq!((actual.spreads.len(), actual.parents.len()), (1, 1));
for (got, want) in actual.spreads.iter().chain(&actual.parents).zip(source.spreads.iter().chain(&source.parents)) {
assert_eq!(got.pages.len(), want.pages.len());
for (got, want) in got.pages.iter().zip(&want.pages) {
assert_eq!(got.liquid, want.liquid);
assert_eq!(got.guides.len(), want.guides.len());
for (got, want) in got.guides.iter().zip(&want.guides) {
assert_eq!(
(got.orientation, got.position, got.spread, got.locked, got.liquid),
(want.orientation, want.position, want.spread, want.locked, want.liquid)
);
assert_eq!(actual.layer(got.layer.unwrap()).unwrap().name, source.layer(want.layer.unwrap()).unwrap().name);
}
}
}
}
+8 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (IDML liquid guide types, #872) · **Target:** Adobe InDesign 2026 (21.6)
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
@@ -31,6 +31,12 @@ score below).
| PageMaker | `.pmd`, `.p65`, `.t65`, `.pmt` | read | ✗ | — | | <1% |
| QuarkXPress | `.qxd`, `.qxt` | read (old versions) | ✗ (#73) | — | | <1% |
IDML preserves stored page liquid rules and distinguishes liquid guides from ordinary ruler
guides on document and parent pages. Guide positions, orientation, Fit to Page, lock state and
layer survive interchange; literal XML and round-trip tests are in
`crates/idml/src/tests/liquid_guides.rs`. Object liquid pins and resize constraints remain unmapped
(#872); this is not full liquid-layout interchange support.
## Output
| Format | Ext | InDesign | DesignCraft | Fidelity / tests | Weight |
@@ -107,5 +113,6 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | IDML retains liquid versus ruler guide types on document and parent pages, with literal XML and mixed-guide round-trip coverage; object liquid constraints remain unsupported (#872) |
| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) |
| 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code |