diff --git a/ROADMAP.md b/ROADMAP.md index 87ce118f3..e45c408a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,13 +6,13 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. -- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - 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 — 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) — 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/compose/src/tests.rs b/crates/compose/src/tests.rs index 4f129c22b..11790836e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -592,7 +592,11 @@ fn keep_lines_together_moves_the_paragraph() { let (mut d, sid, _) = keep_doc(n - 3, &[&long], h); let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(column_of_para(&cs, n - 3).contains(&0)); - d.story_mut(sid).unwrap().paras[n - 3].para.keep_lines_together = Some(true); + { + let p = &mut d.story_mut(sid).unwrap().paras[n - 3].para; + p.keep_lines_together = Some(true); + p.keep_all_lines = Some(true); + } let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(column_of_para(&cs, n - 3).iter().all(|&c| c == 1)); } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 7f456948c..10b3c7a60 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -608,7 +608,8 @@ attr_set! { // Keeps keep_with_next: u32 = 0, keep_lines_together: bool = false, - keep_all_lines: bool = true, + /// All lines in paragraph (true) or at start/end of paragraph (false). + keep_all_lines: bool = false, keep_first: u32 = 2, keep_last: u32 = 2, start_paragraph: StartParagraph = StartParagraph::Anywhere, diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index d294a9509..3a5b340a7 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -273,8 +273,8 @@ impl Styles { self.object.iter().find(|s| s.name == name) } - /// The paragraph style chain from root to `name` (cycle-safe). - fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { + /// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first. + pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { let mut chain = Vec::new(); let mut cur = self.para(name); while let Some(s) = cur { diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 916872027..2ac666291 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -91,6 +91,91 @@ fn magazine_round_trips_through_idml() { assert_eq!(items(&again.spreads).len(), items(&back.spreads).len()); } +/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt), +/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`. +fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec { + use std::io::Write; + let paras: String = lines + .iter() + .enumerate() + .map(|(p, &n)| { + let text = (0..n).map(|k| format!("P{p} line {k}")).collect::>().join("\u{2028}"); + format!( + r#"{text}{}"#, + if p + 1 < lines.len() { "
" } else { "" } + ) + }) + .collect(); + let frame = |me: &str, x: f64, prev: &str, next: &str| { + let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + format!( + r#"{pts}"# + ) + }; + let designmap = format!( + r#" + + + + + +ParagraphStyle/$ID/[No paragraph style]12 + +{paras} +{}{} +"#, + frame("t1", 36.0, "n", "t2"), + frame("t2", 234.0, "t1", "n") + ); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("mimetype", stored).unwrap(); + w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap(); + w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(designmap.as_bytes()).unwrap(); + w.finish().unwrap().into_inner() +} + +/// Lines of paragraph `para` in each frame of the story. +fn para_lines_per_frame(d: &Document, para: usize) -> Vec { + let sid = *d.stories.keys().next().unwrap(); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect() +} + +/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph. +/// A 10-line paragraph starting 6 lines above the first frame's bottom splits 6/4. +#[test] +fn idml_keep_lines_without_mode_split_at_start_and_end() { + let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#; + let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap(); + assert_eq!(para_lines_per_frame(&d, 0), [13, 0]); + assert_eq!(para_lines_per_frame(&d, 1), [6, 4]); + // All lines in paragraph, written explicitly: the paragraph moves whole. + let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap(); + assert_eq!(para_lines_per_frame(&d, 1), [0, 10]); +} + +/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines +/// together after loading; saved now, it keeps lines at start/end of paragraph. +#[test] +fn format_1_keep_lines_without_mode_lays_out_all_lines() { + use std::io::Write; + let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap(); + w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap(); + let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap(); + assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]); + let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap(); + assert_eq!(para_lines_per_frame(&now, 1), [6, 4]); +} + #[test] fn idml_commands() { let mut s = Session::new(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5b4896ccc..e92b6ba08 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -5,18 +5,19 @@ //! - `assets/.`: embedded placed files, byte-for-byte //! - `meta.json`: format version and generator //! -//! Older single-file JSON documents (assets as base64 in `assetData`) still open. +//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents +//! from older format versions are upgraded on load so they lay out as they did. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] use std::io::{Cursor, Read, Write}; use std::sync::Arc; -use designcraft_doc::{AssetId, Document}; +use designcraft_doc::{AssetId, Document, Story}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; -pub const VERSION: u32 = 1; +pub const VERSION: u32 = 2; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -87,12 +88,12 @@ fn load_zip(bytes: &[u8]) -> Result { f.read_to_end(&mut v).ok()?; Some(v) }; - if let Some(meta) = read(&mut z, "meta.json") - && let Ok(m) = serde_json::from_slice::(&meta) - && let Some(v) = m.get("version").and_then(Value::as_u64) - && v as u32 > VERSION - { - return Err(FormatError::TooNew(v as u32)); + let version = read(&mut z, "meta.json") + .and_then(|meta| serde_json::from_slice::(&meta).ok()) + .and_then(|m| m.get("version").and_then(Value::as_u64)) + .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX)); + if version > VERSION { + return Err(FormatError::TooNew(version)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -104,10 +105,51 @@ fn load_zip(bytes: &[u8]) -> Result { Arc::make_mut(a).data = Arc::new(data); } } + upgrade(&mut doc, version); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } +/// Bring a document saved in format `from` up to [`VERSION`]. +fn upgrade(doc: &mut Document, from: u32) { + if from < 2 { + pin_keep_all_lines(doc); + } +} + +/// Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph. Pin that where +/// nothing set it: at the start of each paragraph style chain, and on paragraphs whose style is +/// missing. +fn pin_keep_all_lines(doc: &mut Document) { + let roots: std::collections::HashSet = doc + .styles + .paragraph + .iter() + .filter_map(|s| { + let chain = doc.styles.para_chain(&s.name); + if chain.iter().any(|c| c.para.keep_all_lines.is_some()) { None } else { chain.first().map(|c| c.name.clone()) } + }) + .collect(); + if !roots.is_empty() { + for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) { + s.para.keep_all_lines = Some(true); + } + } + let styles = doc.styles.clone(); + let mut pin = |st: &mut Story| { + for p in st.paras.iter_mut().filter(|p| p.para.keep_all_lines.is_none() && styles.para(&p.style).is_none()) { + p.para.keep_all_lines = Some(true); + } + }; + for st in doc.stories.values_mut() { + let st = Arc::make_mut(st); + st.for_each_text_mut(&mut pin); + for n in &mut st.endnotes { + Arc::make_mut(n).text.for_each_text_mut(&mut pin); + } + } +} + fn load_legacy_json(bytes: &[u8]) -> Result { let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -120,6 +162,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { } } } + upgrade(&mut doc, 1); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -179,6 +222,72 @@ mod tests { assert_eq!(back.stories, d.stories); } + /// `doc` as format 1 wrote it. + fn save_v1(doc: &Document) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap(); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(&serde_json::to_vec(doc).unwrap()).unwrap(); + z.finish().unwrap().into_inner() + } + + fn para_style(name: &str, based_on: Option<&str>, keep_all_lines: Option) -> designcraft_doc::ParagraphStyle { + designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: based_on.map(str::to_string), + next_style: None, + para: designcraft_doc::ParaAttrs { keep_lines_together: Some(true), keep_all_lines, ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + } + } + + /// Format 1 resolved an unset Keep Lines Together mode as all lines: those documents still + /// do after loading, wherever the mode comes from. New documents keep lines at start/end. + #[test] + fn format_1_keep_lines_without_mode_stays_all_lines() { + let mut d = Document::new(&NewDocument::default()); + let st = d.styles_mut(); + st.paragraph.push(para_style("Root", None, None)); + st.paragraph.push(para_style("Child", Some("Root"), None)); + st.paragraph.push(para_style("Explicit", None, Some(false))); + st.paragraph.push(para_style("Under explicit", Some("Explicit"), None)); + st.paragraph.push(para_style("Loop A", Some("Loop B"), None)); + st.paragraph.push(para_style("Loop B", Some("Loop A"), None)); + let lid = d.default_layer(); + let names = [designcraft_doc::story::BASIC_PARAGRAPH, "Root", "Child", "Explicit", "Under explicit", "Loop A", "Loop B", "Missing"]; + let text = names.join("\n"); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, &text, ParaFormat::default()).unwrap(); + { + let s = d.story_mut(sid).unwrap(); + for (p, name) in s.paras.iter_mut().zip(names) { + p.style = name.into(); + p.para.keep_lines_together = Some(true); + } + s.insert_note(1, "Note", ParaFormat { style: "Missing".into(), ..Default::default() }); + } + let back = load(&save_v1(&d)).unwrap(); + let resolve = |f: &ParaFormat| back.styles.resolve_para(f).0; + let story = back.story(sid).unwrap(); + for (p, name) in story.paras.iter().zip(names) { + let pp = resolve(p); + assert!(pp.keep_lines_together); + assert_eq!(pp.keep_all_lines, !name.contains("xplicit"), "{name}"); + } + assert!(resolve(&story.notes[0].text.paras[0]).keep_all_lines, "footnote with a missing style"); + assert!(back.styles.resolve_para_style("Loop A").0.keep_all_lines && back.styles.resolve_para_style("Loop B").0.keep_all_lines); + // Pinned where the chains start; explicit and inheriting styles are left as they were. + assert_eq!(back.styles.para("Root").unwrap().para.keep_all_lines, Some(true)); + assert_eq!(back.styles.para("Child").unwrap().para.keep_all_lines, None); + assert_eq!(back.styles.para("Under explicit").unwrap().para.keep_all_lines, None); + // The current format keeps the new default: at start/end of paragraph. + let now = load(&save(&d).unwrap()).unwrap(); + let p = &now.story(sid).unwrap().paras[0]; + assert!(!now.styles.resolve_para(p).0.keep_all_lines); + assert!(!designcraft_doc::ParaProps::default().keep_all_lines); + } + #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 02fe63ff8..249d29c0e 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -194,6 +194,48 @@ fn fixture_with_story(story: &str) -> Vec { ]) } +fn fixture_with_styles(styles: &str) -> Vec { + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]) +} + +/// Paragraph styles `Body` (based on the root) and `Loose` (no BasedOn), both with `attrs`. +fn keep_styles(attrs: &str) -> String { + format!( + r#" + + + + + ParagraphStyle/$ID/[No paragraph style] + + + +"# + ) +} + +/// Keep Lines Together whose mode no style sets is InDesign's default: at start/end of paragraph. +/// An explicit mode still wins. +#[test] +fn keep_lines_mode_defaults_to_start_and_end() { + let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#; + for (attrs, all) in [(keep.to_string(), false), (format!(r#"{keep} KeepAllLinesTogether="true""#), true)] { + let d = import_idml_with(&fixture_with_styles(&keep_styles(&attrs)), &|_| None).unwrap(); + for name in ["Body", "Loose"] { + let (pp, _) = d.styles.resolve_para_style(name); + assert_eq!((pp.keep_lines_together, pp.keep_all_lines, pp.keep_first, pp.keep_last), (true, all, 2, 2), "{name}: {attrs}"); + } + } +} + #[test] fn imports_hand_written_fixture() { let d = import_idml_with(&fixture(), &|_| None).unwrap();