diff --git a/ROADMAP.md b/ROADMAP.md index f5eb04ccf..5a442cef1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). - Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. - Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 16a387067..6faf583a7 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -11,7 +11,7 @@ use crate::ids::{ItemId, LayerId, PageId, SpreadId}; use crate::item::Item; use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread}; use crate::styles::Styles; -use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, Result}; +use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef}; /// A New Document preset. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -164,9 +164,38 @@ impl Document { let pages: Vec = (0..nd.pages.max(1)).map(|_| d.make_page(Some(parent_id), nd.columns, nd.gutter, nd.margins)).collect(); d.spreads = vec![Arc::new(Spread { id: SpreadId(d.alloc()), pages, items: vec![], parent: None, allow_shuffle: true })]; d.repaginate(); + if nd.primary_text_frame { + d.add_primary_frames(nd.columns, nd.gutter); + } d } + /// Primary Text Frame: a margin-sized frame on every page, threaded into one story. + fn add_primary_frames(&mut self, columns: u32, gutter: f64) { + let layer = self.default_layer(); + let mut prev: Option = None; + let mut story = None; + for page in 0..self.page_count() { + let Some((si, pi)) = self.page_loc(page) else { continue }; + let r = self.spreads[si].pages[pi].margin_rect(); + let Ok((fid, sid)) = self.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default()) else { continue }; + if let Some(it) = self.item_mut(fid) + && let Some(tf) = it.text_frame_mut() + { + tf.options.columns = columns.max(1); + tf.options.gutter = gutter; + } + match prev { + Some(p) => { + let _ = self.thread(p, fid); + } + None => story = Some(sid), + } + prev = Some(fid); + } + self.settings.primary_story = story; + } + fn make_page(&mut self, parent: Option, columns: u32, gutter: f64, margins: Margins) -> Page { Page { id: PageId(self.alloc()), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4eabb9d7f..e8ab82dff 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -139,6 +139,9 @@ pub struct DocSettings { pub bleed_color: [u8; 3], pub slug_color: [u8; 3], pub keyboard_increment: f64, + /// The story in the primary text frames (Smart Text Reflow adds and removes pages for it). + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_story: Option, /// Chapter number (Numbering & Section Options › Document Chapter Numbering). pub chapter_number: u32, } @@ -163,6 +166,7 @@ impl Default for DocSettings { bleed_color: [255, 72, 103], slug_color: [100, 188, 221], keyboard_increment: 1.0, + primary_story: None, chapter_number: 1, } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 60e30ddd8..cab4c7630 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -219,3 +219,4 @@ pub fn file_from(b: &[u8]) -> Result { file::from_bytes(b) } pub use file::{base64_decode, base64_encode, from_bytes, to_bytes}; +pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 43310bf48..cfd07223d 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -47,7 +47,7 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize) { } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. -fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result { +pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result { let mut added = 0; loop { let (overset, shown) = fit(d, sid); @@ -254,3 +254,39 @@ mod place_pdf_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod reflow_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn smart_text_reflow_adds_and_removes_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "primaryTextFrame": true})).unwrap(); + let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story").0; + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().frames.len(), 1); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let long = "Words keep coming and the page fills up quickly with them. ".repeat(250); + s.execute("text.insert", &json!({"text": long})).unwrap(); + let pages = s.doc().unwrap().doc.page_count(); + assert!(pages >= 3, "pages added: {pages}"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().frames.len(), pages); + // One undo takes the typing and its pages back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), 1); + s.execute("edit.redo", &json!({})).unwrap(); + // Delete the text: the empty pages at the end go. + let n = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().len(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), 1); + s.doc().unwrap().doc.check().unwrap(); + // Off: no pages added. + s.execute("prefs.set", &json!({"smartTextReflow": false})).unwrap(); + s.execute("text.insert", &json!({"text": long})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), 1); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index faee76b7d..f443c83cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -131,6 +131,9 @@ pub struct Prefs { pub scale_strokes: bool, /// X/Y/W/H in the Control and Properties panels measure the stroke's outer edge. pub dimensions_include_stroke: bool, + /// Preferences › Type › Smart Text Reflow: pages follow the primary text frame's story + /// (added while it oversets, empty ones at the end removed). + pub smart_text_reflow: bool, } impl Default for Prefs { @@ -142,6 +145,7 @@ impl Default for Prefs { star_inset: 0.0, scale_strokes: true, dimensions_include_stroke: true, + smart_text_reflow: true, } } } @@ -240,6 +244,9 @@ impl Session { let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; self.record_transform(id, params); + if self.prefs.smart_text_reflow && spec.undoable { + self.smart_reflow(); + } // Record undo if the document changed (and we're not previewing an interaction). if let (Some((uid, old)), Some(st)) = (before, self.active_mut()) && st.uid == uid @@ -256,6 +263,51 @@ impl Session { Ok(r) } + /// Smart Text Reflow for the primary story: add threaded pages while it oversets; remove + /// trailing pages whose only object is an empty frame of it. + fn smart_reflow(&mut self) { + let Some(st) = self.active() else { return }; + if st.interaction.is_some() { + return; + } + let Some(sid) = st.doc.settings.primary_story else { return }; + let Some(story) = st.doc.story(sid) else { return }; + let cs = self.cache.get(&st.doc, sid, None); + let overset = cs.overset_at.is_some(); + let empty_tail = story.frames.len() > 1 && story.frames.last().is_some_and(|f| cs.frame(*f).is_none_or(|ft| ft.range.is_empty())); + if !overset && !empty_tail { + return; + } + let mut d = (*st.doc).clone(); + if overset { + let _ = cmd::place_text_autoflow(&mut d, sid, 500); + } else { + // Drop empty trailing frames on the last pages (one frame, nothing else there). + while let Some(story) = d.story(sid) { + if story.frames.len() < 2 { + break; + } + let last = *story.frames.last().expect("frames"); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + if cs.frame(last).is_some_and(|ft| !ft.range.is_empty()) { + break; + } + let Some(page) = d.page_of_item(last) else { break }; + let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).filter(|i| d.page_of_item(i.id) == Some(page)).count() == 1; + if page + 1 != d.page_count() || !alone || d.page_count() < 2 { + break; + } + if d.remove_item(last).is_err() || d.delete_pages(&[page]).is_err() { + break; + } + } + } + if let Some(st) = self.active_mut() { + st.doc = Arc::new(d); + st.revision += 1; + } + } + /// Remember a move/rotate/scale/shear/flip for Transform Again. fn record_transform(&mut self, id: &str, params: &Value) { if !matches!(id, "transform.move" | "transform.rotate" | "transform.scale" | "transform.shear" | "transform.flip") { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 468141c2a..ecc11a3a4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -234,6 +234,9 @@ fn preferences(ui: &mut egui::Ui, d: &mut Dialog) { "type" => { ui.label(egui::RichText::new("Type Options").font(semibold(12.0))); check(ui, d, "typographersQuotes", "Use Typographer's Quotes"); + ui.add_space(6.0); + ui.label(egui::RichText::new("Smart Text Reflow").font(semibold(12.0))); + check(ui, d, "smartTextReflow", "Add and remove pages as the primary text frame's story grows and shrinks"); } "interface" => { ui.label(egui::RichText::new("UI Scaling").font(semibold(12.0))); @@ -703,7 +706,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "preferences" => { app.run( "prefs.set", - json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "typographersQuotes": d.b("typographersQuotes")}), + json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "typographersQuotes": d.b("typographersQuotes"), "smartTextReflow": d.b("smartTextReflow")}), )?; let view = match d.s("displayQuality").as_str() { "fast" => "view.fastDisplay", diff --git a/docs/parity.md b/docs/parity.md index 41087277e..162cd4929 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,13 +10,13 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 50 | 23 | 24 | 63% | +| P1 | 97 | 51 | 23 | 23 | 64% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | |---|---| | Application shell & workspace | 74% | -| Documents, pages, spreads | 71% | +| Documents, pages, spreads | 74% | | Layers | 95% | | Frames, shapes & paths | 85% | | Transform | 93% | @@ -70,7 +70,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Documents, pages, spreads | Pasteboard | P0 | D | | Documents, pages, spreads | Sections & numbering | P0 | D | | Documents, pages, spreads | Page numbering display | P1 | P | -| Documents, pages, spreads | Smart Text Reflow | P1 | M | +| Documents, pages, spreads | Smart Text Reflow | P1 | D | | Documents, pages, spreads | Primary text frame | P1 | P | | Documents, pages, spreads | Parent pages | P0 | D | | Documents, pages, spreads | Parent item overrides | P0 | D |