From 4a2a9ff7ddf71693583c9f3cf645110d19d2ce97 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 19:08:26 -0400 Subject: [PATCH] IDML: absent attributes take the defaults IDML files assume Audit of every attribute the IDML importer reads against the value an IDML file assumes when it leaves the attribute out. Paragraph, character, text frame, page item, table and preference values already match except the corner size: a corner shape without a radius is now 12 pt instead of a square corner. Table-driven tests pin those defaults for the text attributes (omitted, written as defaults, written otherwise) and for page items, frames, tables and document preferences. --- ROADMAP.md | 2 +- crates/idml/src/import.rs | 5 +- crates/idml/src/tests.rs | 209 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e45c408a4..2bc132df3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. -- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). +- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 107f72077..b67a29a04 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -158,6 +158,9 @@ fn parse_point(s: Option<&str>) -> Option { (v.len() >= 2).then(|| Point::new(v[0], v[1])) } +/// InDesign's corner size where a file gives a corner shape without one. +const INDESIGN_CORNER_SIZE: f64 = 12.0; + /// IDML tint (percent, `-1` = default) → 0..1. fn tint(v: Option) -> Option { v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 }) @@ -1850,7 +1853,7 @@ impl<'r> Importer<'r> { let mut corners = CornerOptions::default(); for (i, n) in cn.iter().enumerate() { let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default(); - let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0); + let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(INDESIGN_CORNER_SIZE); corners.corners[i] = Corner { shape, size }; } if !corners.is_none() { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 249d29c0e..c8251e6d2 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -236,6 +236,215 @@ fn keep_lines_mode_defaults_to_start_and_end() { } } +type Resolved = (designcraft_doc::ParaProps, designcraft_doc::CharProps); + +/// (IDML attribute, InDesign's default as IDML writes it, another value, the resolved field, +/// its value for InDesign's default, its value for the other value). +type AttrRow = (&'static str, &'static str, &'static str, fn(&Resolved) -> String, String, String); + +macro_rules! attr_rows { + ($($attr:literal: $default:literal => $dv:expr, $other:literal => $ov:expr, |$r:ident| $field:expr;)*) => { + vec![$(($attr, $default, $other, (|$r: &Resolved| format!("{:?}", $field)) as fn(&Resolved) -> String, format!("{:?}", $dv), format!("{:?}", $ov)),)*] + }; +} + +/// InDesign's defaults for a new document's [No Paragraph Style], for every paragraph and +/// character attribute the importer reads (except the font family: see below). +fn indesign_text_defaults() -> Vec { + use designcraft_doc::{Align, Capitalization, Composer, Digits, GridAlign, Leading, ListType, Position, StartParagraph, TextDirection}; + attr_rows! { + "Justification": "LeftAlign" => Align::Left, "CenterAlign" => Align::Center, |r| r.0.align; + "ParagraphDirection": "LeftToRightDirection" => TextDirection::LeftToRight, "RightToLeftDirection" => TextDirection::RightToLeft, |r| r.0.direction; + "LeftIndent": "0" => 0.0, "6" => 6.0, |r| r.0.left_indent; + "RightIndent": "0" => 0.0, "6" => 6.0, |r| r.0.right_indent; + "FirstLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.first_line_indent; + "LastLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.last_line_indent; + "SpaceBefore": "0" => 0.0, "6" => 6.0, |r| r.0.space_before; + "SpaceAfter": "0" => 0.0, "6" => 6.0, |r| r.0.space_after; + "DropCapLines": "0" => 0, "3" => 3, |r| r.0.drop_cap_lines; + "DropCapCharacters": "0" => 0, "1" => 1, |r| r.0.drop_cap_chars; + "GridAlignment": "None" => GridAlign::None, "AlignToBaseline" => GridAlign::AllLines, |r| r.0.grid_align; + "Composer": "HL Composer" => Composer::Paragraph, "HL Single" => Composer::SingleLine, |r| r.0.composer; + "Hyphenation": "true" => true, "false" => false, |r| r.0.hyphenate; + "HyphenateWordsLongerThan": "5" => 5, "7" => 7, |r| r.0.hyph_min_word; + "HyphenateAfterFirst": "2" => 2, "3" => 3, |r| r.0.hyph_after_first; + "HyphenateBeforeLast": "2" => 2, "3" => 3, |r| r.0.hyph_before_last; + "HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit; + "HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone; + "HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized; + "HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word; + "HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column; + "HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight; + "MinimumWordSpacing": "80" => 0.8, "70" => 0.7, |r| r.0.word_space_min; + "DesiredWordSpacing": "100" => 1.0, "90" => 0.9, |r| r.0.word_space_desired; + "MaximumWordSpacing": "133" => 1.33, "150" => 1.5, |r| r.0.word_space_max; + "MinimumLetterSpacing": "0" => 0.0, "-5" => -0.05, |r| r.0.letter_space_min; + "DesiredLetterSpacing": "0" => 0.0, "1" => 0.01, |r| r.0.letter_space_desired; + "MaximumLetterSpacing": "0" => 0.0, "5" => 0.05, |r| r.0.letter_space_max; + "MinimumGlyphScaling": "100" => 1.0, "97" => 0.97, |r| r.0.glyph_scale_min; + "DesiredGlyphScaling": "100" => 1.0, "101" => 1.01, |r| r.0.glyph_scale_desired; + "MaximumGlyphScaling": "100" => 1.0, "103" => 1.03, |r| r.0.glyph_scale_max; + "AutoLeading": "120" => 1.2, "100" => 1.0, |r| r.0.auto_leading; + "SingleWordJustification": "FullyJustified" => Align::FullyJustified, "LeftAlign" => Align::Left, |r| r.0.single_word_justify; + "KeepWithNext": "0" => 0, "1" => 1, |r| r.0.keep_with_next; + "KeepLinesTogether": "false" => false, "true" => true, |r| r.0.keep_lines_together; + "KeepAllLinesTogether": "false" => false, "true" => true, |r| r.0.keep_all_lines; + "KeepFirstLines": "2" => 2, "3" => 3, |r| r.0.keep_first; + "KeepLastLines": "2" => 2, "3" => 3, |r| r.0.keep_last; + "StartParagraph": "Anywhere" => StartParagraph::Anywhere, "NextColumn" => StartParagraph::NextColumn, |r| r.0.start_paragraph; + "BulletsAndNumberingListType": "NoList" => ListType::None, "BulletList" => ListType::Bullets, |r| r.0.list_type; + "BalanceRaggedLines": "NoBalancing" => false, "FullyBalanced" => true, |r| r.0.balance_ragged; + "RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on; + "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; + "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; + "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; + "FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style; + "PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size; + "Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading; + "KerningMethod": "$ID/Metrics" => designcraft_doc::Kerning::Metrics, "$ID/Optical" => designcraft_doc::Kerning::Optical, |r| r.1.kerning; + "Tracking": "0" => 0.0, "20" => 20.0, |r| r.1.tracking; + "HorizontalScale": "100" => 1.0, "90" => 0.9, |r| r.1.h_scale; + "VerticalScale": "100" => 1.0, "90" => 0.9, |r| r.1.v_scale; + "BaselineShift": "0" => 0.0, "2" => 2.0, |r| r.1.baseline_shift; + "Skew": "0" => 0.0, "10" => 10.0, |r| r.1.skew; + "FillColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.1.fill; + "FillTint": "-1" => 1.0, "50" => 0.5, |r| r.1.fill_tint; + "StrokeColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.1.stroke; + "StrokeTint": "-1" => 1.0, "50" => 0.5, |r| r.1.stroke_tint; + "StrokeWeight": "1" => 1.0, "2" => 2.0, |r| r.1.stroke_weight; + "Capitalization": "Normal" => Capitalization::Normal, "AllCaps" => Capitalization::AllCaps, |r| r.1.capitalization; + "Position": "Normal" => Position::Normal, "Superscript" => Position::Superscript, |r| r.1.position; + "Underline": "false" => false, "true" => true, |r| r.1.underline; + "StrikeThru": "false" => false, "true" => true, |r| r.1.strikethrough; + "Ligatures": "true" => true, "false" => false, |r| r.1.ligatures; + "NoBreak": "false" => false, "true" => true, |r| r.1.no_break; + "OTFContextualAlternate": "true" => true, "false" => false, |r| designcraft_doc::otf::is_on(&r.1.otf_features, "calt"); + "DigitsType": "DefaultDigits" => Digits::Default, "ArabicDigits" => Digits::Arabic, |r| r.1.digits; + "AppliedLanguage": "$ID/English: USA" => "English: USA", "$ID/German: 2006 Reform" => "German: 2006 Reform", |r| r.1.language; + } +} + +/// [No paragraph style] with `root` attributes and `Body`, based on it, with `body` attributes. +fn text_styles(root: &str, body: &str) -> String { + format!( + r#" + + + + + ParagraphStyle/$ID/[No paragraph style] + + +"# + ) +} + +fn resolve_body(root: &str, body: &str) -> Resolved { + let d = import_idml_with(&fixture_with_styles(&text_styles(root, body)), &|_| None).unwrap(); + d.styles.resolve_para_style("Body") +} + +/// Text attributes an IDML file leaves unset are InDesign's defaults; written ones win, and a +/// file that writes every default (as InDesign's own exports do) imports the same. +#[test] +fn absent_text_attributes_take_indesign_defaults() { + let rows = indesign_text_defaults(); + let all = |pick: fn(&AttrRow) -> &str| rows.iter().map(|r| format!(r#"{}="{}""#, r.0, pick(r))).collect::>().join(" "); + let (defaults, others) = (all(|r| r.1), all(|r| r.2)); + let omitted = resolve_body("", ""); + let written = resolve_body(&defaults, ""); + for (attr, _, _, field, want, _) in &rows { + assert_eq!(field(&omitted), *want, "{attr} omitted"); + assert_eq!(field(&written), *want, "{attr} written as InDesign's default"); + } + assert_eq!(omitted, written); + for (root, body) in [(others.as_str(), ""), ("", others.as_str())] { + let r = resolve_body(root, body); + for (attr, _, _, field, _, want) in &rows { + assert_eq!(field(&r), *want, "{attr} written on the {}", if root.is_empty() { "style" } else { "root" }); + } + } + // Product difference: InDesign's default face isn't available, so unset fonts are DesignCraft's. + assert_eq!(omitted.1.font_family, designcraft_doc::CharProps::default().font_family); +} + +/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults +/// for a new document. +#[test] +fn absent_item_and_document_attributes_take_indesign_defaults() { + use designcraft_doc::{Cap, FirstBaseline, Join, StrokeAlign, VerticalJustification}; + use designcraft_geom::corners::CornerShape; + let path = |x: f64| { + let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + format!( + r#"{pts}"# + ) + }; + let designmap = format!( + r#" + +
+ +{} +{} +{} +{} +{} + +
"#, + path(0.0), + path(100.0), + path(200.0), + path(300.0), + path(400.0) + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap(); + // Document Setup, margins and columns, grids, Advanced Type, transparency, black. + let s = &d.settings; + assert_eq!((s.page_width, s.page_height, s.facing_pages, s.intent), (612.0, 792.0, true, designcraft_doc::Intent::Print)); + assert_eq!((s.bleed, s.slug), ([0.0; 4], [0.0; 4])); + let page = &d.spreads[0].pages[0]; + assert_eq!((page.margins.top, page.margins.bottom, page.margins.inside, page.margins.outside), (36.0, 36.0, 36.0, 36.0)); + assert_eq!((page.columns.count, page.columns.gutter), (1, 12.0)); + assert_eq!((s.baseline_grid.start, s.baseline_grid.increment, s.baseline_grid.view_threshold), (36.0, 12.0, 0.75)); + assert_eq!((s.grid.horizontal, s.grid.vertical, s.grid.subdivisions, s.grid.in_back), (72.0, 72.0, 8, true)); + let a = &s.advanced_type; + assert_eq!((a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position), (58.3, 33.3, 58.3, 33.3)); + assert_eq!((s.blend_space, s.overprint_black, s.keyboard_increment), (designcraft_doc::BlendSpace::Cmyk, true, 1.0)); + // Fill, stroke, transparency, wrap and corners of a frame with no attributes. + let item = |n: usize| d.spreads[0].items[n].clone(); + let plain = item(0); + assert!(plain.fill.is_none() && plain.stroke.is_none()); + let st = &plain.stroke; + assert_eq!((st.weight, st.miter_limit, st.cap, st.join, st.align), (1.0, 4.0, Cap::Butt, Join::Miter, StrokeAlign::Center)); + assert_eq!((plain.opacity, plain.blend, plain.wrap.mode), (1.0, designcraft_color::BlendMode::Normal, designcraft_doc::WrapMode::None)); + assert!(plain.corners.is_none()); + // A corner shape without a size: 12 pt. + for n in [1, 2] { + assert_eq!(item(n).corners.corners.map(|c| (c.shape, c.size)), [(CornerShape::Rounded, 12.0); 4], "item {n}"); + } + assert_eq!(item(3).corners.corners[0].size, 5.0); + // Text Frame Options. + let tf = item(4); + let o = &tf.text_frame().unwrap().options; + assert_eq!((o.columns, o.gutter, o.inset, o.balance_columns, o.ignore_wrap), (1, 12.0, [0.0; 4], false, false)); + assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Top, FirstBaseline::Ascent, 0.0)); + assert_eq!(o.auto_size, designcraft_doc::AutoSize::Off); + // Table and cell options. + let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!((t.options.space_before, t.options.space_after, t.options.repeat_header), (4.0, -4.0, true)); + assert_eq!((t.options.border.weight, t.options.border.color.as_str()), (1.0, "[Black]")); + assert_eq!((t.rows[0].mode, t.rows[0].height), (designcraft_doc::RowHeightMode::AtLeast, 3.0)); + let c = t.cell(0, 0).unwrap(); + assert_eq!((c.insets, c.vj, c.fill.as_str()), ([4.0; 4], VerticalJustification::Top, "[None]")); + for e in &c.strokes { + assert_eq!((e.weight, e.color.as_str(), e.tint, &e.kind), (1.0, "[Black]", 1.0, &designcraft_doc::StrokeType::Solid)); + } +} + #[test] fn imports_hand_written_fixture() { let d = import_idml_with(&fixture(), &|_| None).unwrap();