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();