From 605513228a223b792fa5f5daa3601008bbd5d795 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 18:55:57 +0000 Subject: [PATCH] Text frames: auto-size grows and shrinks frames to fit their text Height Only, Width Only, Height and Width and Keep Proportions frames fit their text after every undoable command (same undo step) and after IDML import, around the reference point, with minimum height/width and No Line Breaks. Text Frame Options gains an Auto-Size section; IDML reads and writes the minimum and no-line-break attributes. Applies to unthreaded frames and the last frame of a thread; vertical frames and type on a path are left as they are. (#180) --- ROADMAP.md | 1 + crates/doc/src/item.rs | 20 ++- crates/engine/src/autosize.rs | 246 +++++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/interchange.rs | 2 + crates/engine/src/cmd/object.rs | 31 +++- crates/engine/src/lib.rs | 19 +++ crates/engine/src/tests_autosize.rs | 193 +++++++++++++++++++++ crates/idml/src/export.rs | 7 +- crates/idml/src/import.rs | 6 + crates/ui-egui/src/dialogs.rs | 71 ++++++++ docs/gaps.md | 2 +- docs/target-app-parity.md | 1 + 13 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/autosize.rs create mode 100644 crates/engine/src/tests_autosize.rs diff --git a/ROADMAP.md b/ROADMAP.md index f826a376..f3bccdba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text frame auto-size: Height Only, Width Only, Height and Width (also keeping proportions) frames grow and shrink to fit their text after edits and IDML import, around their reference point, with minimum sizes and No Line Breaks, in the same undo step (#180). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe..a7199871 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -321,8 +321,21 @@ pub struct TextFrameOptions { pub first_baseline_min: f64, pub ignore_wrap: bool, pub auto_size: AutoSize, - /// Reference point (0..9) that stays fixed when auto-sizing. + /// Reference point (0..9, row-major from top-left) that stays fixed when auto-sizing. pub auto_size_ref: u8, + /// Auto-size never makes the frame shorter than `auto_size_min_height` while this is on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_use_min_height: bool, + #[serde(skip_serializing_if = "is_default")] + pub auto_size_min_height: f64, + /// Auto-size never makes the frame narrower than `auto_size_min_width` while this is on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_use_min_width: bool, + #[serde(skip_serializing_if = "is_default")] + pub auto_size_min_width: f64, + /// Width-growing auto-size sets each paragraph on one line (the frame widens to the longest). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_no_line_breaks: bool, pub column_rule: bool, pub column_rule_weight: f64, pub column_rule_color: String, @@ -379,6 +392,11 @@ impl Default for TextFrameOptions { ignore_wrap: false, auto_size: AutoSize::Off, auto_size_ref: 1, + auto_size_use_min_height: false, + auto_size_min_height: 0.0, + auto_size_use_min_width: false, + auto_size_min_width: 0.0, + auto_size_no_line_breaks: false, column_rule: false, column_rule_weight: 1.0, column_rule_color: designcraft_color::swatch::BLACK.into(), diff --git a/crates/engine/src/autosize.rs b/crates/engine/src/autosize.rs new file mode 100644 index 00000000..b3dcf03b --- /dev/null +++ b/crates/engine/src/autosize.rs @@ -0,0 +1,246 @@ +//! Text frame auto-size (Text Frame Options › Auto-Size): frames grow and shrink to fit their text. +//! +//! [`apply`] runs after every undoable command (so the resize is part of the same undo step) and +//! after IDML import. It measures each auto-size frame whose story, styles or own item changed, +//! with the frame opened to [`MAX_SIDE`], and resizes it in its inner space around its reference +//! point, so that point stays put in spread space whatever the frame's transform. +//! +//! Scope: an unthreaded frame or the last frame of a thread. Vertical (Japanese) frames and type +//! on a path are left as they are. + +use std::sync::Arc; + +use designcraft_compose::{ComposeOptions, ComposedStory, FrameSpec, compose, frame_specs}; +use designcraft_doc::{AutoSize, Document, Item, ItemId, StoryId}; +use designcraft_geom::{Affine, Rect, shapes}; + +/// Auto-size reference points, row-major from top-left (`TextFrameOptions::auto_size_ref`). +pub const REFERENCES: [&str; 9] = + ["topLeft", "topCenter", "topRight", "leftCenter", "center", "rightCenter", "bottomLeft", "bottomCenter", "bottomRight"]; + +/// Largest side an auto-sized frame grows to (points). +pub const MAX_SIDE: f64 = 10_000.0; +/// Size differences below this are not worth a resize (points). +const EPS: f64 = 0.01; +/// Bisection steps for multi-column and proportional fitting. +const STEPS: usize = 24; + +/// Resize the auto-size frames of `d` that `before` (the document before the edit) had with other +/// text, styles or geometry; every auto-size frame when `before` is `None`. Returns whether any +/// frame changed. +pub fn apply(d: &mut Document, before: Option<&Document>) -> bool { + let todo = candidates(d, before); + fit(d, todo) +} + +/// Resize the frames `todo` to fit their text. Measuring is exact, so the second pass only +/// re-checks the frames the first one resized (their own wrap or neighbours may differ); the cap +/// keeps a pathological wrap from oscillating. +pub fn fit(d: &mut Document, mut todo: Vec<(ItemId, StoryId)>) -> bool { + let mut changed = false; + for _ in 0..3 { + if todo.is_empty() { + break; + } + let mut resized = Vec::new(); + for (fid, sid) in todo { + if let Some(r) = fitted(d, fid, sid) + && resize(d, fid, r) + { + resized.push((fid, sid)); + } + } + changed |= !resized.is_empty(); + todo = resized; + } + changed +} + +/// Auto-size frames (id, story) whose story, styles or item changed since `before` (all of them +/// without it). +pub fn candidates(d: &Document, before: Option<&Document>) -> Vec<(ItemId, StoryId)> { + fn walk(items: &[Arc], out: &mut Vec<(ItemId, StoryId)>) { + for it in items { + if let Some(tf) = it.text_frame() + && tf.options.auto_size != AutoSize::Off + { + out.push((it.id, tf.story)); + } + walk(it.children(), out); + } + } + let mut all = Vec::new(); + for sp in d.spreads.iter().chain(d.parents.iter()) { + walk(&sp.items, &mut all); + } + let Some(b) = before else { return all }; + let styles_same = Arc::ptr_eq(&d.styles, &b.styles); + all.retain(|(fid, sid)| { + let story_same = match (d.stories.get(sid), b.stories.get(sid)) { + (Some(x), Some(y)) => Arc::ptr_eq(x, y), + _ => false, + }; + // The frame and the frames before it in the thread (they decide where its text starts). + let same = |id: &ItemId| match (d.item(*id), b.item(*id)) { + (Some(x), Some(y)) => x == y, + _ => false, + }; + let frames_same = same(fid) && d.stories.get(sid).is_some_and(|st| st.frames.iter().all(same)); + !(styles_same && story_same && frames_same) + }); + all +} + +/// What one composition of the frame says: the text height from the area top, the width the +/// longest line needs, and whether all of the story fits. +struct Measure { + height: f64, + width: f64, + fits: bool, +} + +/// The frame's new inner bounds, if auto-size applies and they differ from the current ones. +pub fn fitted(d: &Document, fid: ItemId, sid: StoryId) -> Option { + let item = d.item(fid)?; + let tf = item.text_frame()?; + let o = &tf.options; + if o.auto_size == AutoSize::Off || o.path.is_some() || d.frame_vertical(item) { + return None; + } + let story = d.story(sid)?; + if story.frames.last() != Some(&fid) { + return None; + } + let r = item.inner_bounds(); + let (w, h) = (r.width(), r.height()); + if !(w.is_finite() && h.is_finite() && r.x0.is_finite() && r.y0.is_finite()) { + return None; + } + let inset = o.inset.map(|v| if v.is_finite() { v.clamp(0.0, MAX_SIDE) } else { 0.0 }); + let (pad_w, pad_h) = (inset[1] + inset[3], inset[0] + inset[2]); + let specs = frame_specs(d, sid); + let idx = specs.iter().position(|s| s.id == fid)?; + let columns = o.columns.max(1); + let gutter = if o.gutter.is_finite() { o.gutter.clamp(0.0, MAX_SIDE) } else { 0.0 }; + // Compose with the frame's text area set to `aw` × `ah`. + let measure = |aw: f64, ah: f64| -> Option { + let mut specs: Vec = specs.clone(); + let s = specs.get_mut(idx)?; + let a = s.area; + let area = Rect::new(a.x0, a.y0, a.x0 + aw.clamp(0.0, MAX_SIDE), a.y0 + ah.clamp(0.0, MAX_SIDE)); + s.area = area; + let cs: ComposedStory = compose(d, story, &specs, &ComposeOptions::default()); + let ft = cs.frame(fid)?; + let width = ft + .lines + .iter() + .map(|l| { + let col = ft.columns.get(l.column as usize).copied().unwrap_or(area); + let (lo, hi) = l.glyphs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.x), hi.max(g.x + g.adv))); + let ink = if hi >= lo { hi - lo } else { 0.0 }; + // Indents: the line slot is the column minus them. + ink + (l.x0 - col.x0).max(0.0) + (col.x1 - l.x1).max(0.0) + }) + .fold(0.0, f64::max); + let width = columns as f64 * width + (columns - 1) as f64 * gutter; + let height = if ft.lines.is_empty() { 0.0 } else { ft.content_height }; + Some(Measure { height, width, fits: cs.overset_at.is_none() }) + }; + // Height of the text set `aw` wide (a multi-column frame by bisection: its text spreads over the + // columns). + let text_height = |aw: f64| -> Option { + let tall = measure(aw, MAX_SIDE)?; + if columns == 1 || !tall.fits { + return Some(tall.height); + } + let (mut lo, mut hi) = (0.0, tall.height); + for _ in 0..STEPS { + if hi - lo < EPS { + break; + } + let mid = (lo + hi) / 2.0; + if measure(aw, mid).is_some_and(|m| m.fits) { hi = mid } else { lo = mid } + } + Some(hi) + }; + // Width of the longest line set without breaks (plus a hair, so it doesn't break when set). + let unbroken_width = || -> Option { measure(MAX_SIDE, MAX_SIDE).map(|m| m.width + EPS) }; + let minimum = |on: bool, v: f64| if on && v.is_finite() { v.clamp(0.0, MAX_SIDE) } else { 0.0 }; + let min_h = minimum(o.auto_size_use_min_height, o.auto_size_min_height); + let min_w = minimum(o.auto_size_use_min_width, o.auto_size_min_width); + let nlb = o.auto_size_no_line_breaks; + let (nw, nh) = match o.auto_size { + AutoSize::Off => return None, + AutoSize::HeightOnly => (w, text_height(w - pad_w)? + pad_h), + AutoSize::WidthOnly => (unbroken_width()? + pad_w, h), + AutoSize::HeightAndWidth if nlb => { + let nw = (unbroken_width()? + pad_w).max(min_w); + (nw, text_height(nw - pad_w)? + pad_h) + } + AutoSize::HeightAndWidth => (w, text_height(w - pad_w)? + pad_h), + AutoSize::HeightAndWidthProportionally => { + if w < EPS || h < EPS { + return None; + } + let k = if nlb { + let nw = (unbroken_width()? + pad_w).max(min_w); + let nh = text_height(nw - pad_w)? + pad_h; + (nw / w).max(nh / h) + } else { + // The smallest scale at which the text fits the scaled frame: the text gets + // shorter as the frame widens, the frame taller, so one crossing. (Columns are not + // balanced here: the text height is measured as one column.) + let fits = |k: f64| measure(w * k - pad_w, MAX_SIDE).is_some_and(|m| m.height + pad_h <= h * k + EPS); + let (mut lo, mut hi) = (EPS / w.min(h), MAX_SIDE / w.max(h)); + if !fits(hi) { + lo = hi; + } + for _ in 0..STEPS { + if hi - lo < 1e-4 { + break; + } + let mid = (lo + hi) / 2.0; + if fits(mid) { hi = mid } else { lo = mid } + } + hi.max(min_w / w).max(min_h / h) + }; + (w * k, h * k) + } + }; + let grows_w = !matches!(o.auto_size, AutoSize::HeightOnly); + let grows_h = !matches!(o.auto_size, AutoSize::WidthOnly); + let nw = if grows_w { nw.max(min_w).max(pad_w) } else { w }; + let nh = if grows_h { nh.max(min_h).max(pad_h) } else { h }; + if !(nw.is_finite() && nh.is_finite()) { + return None; + } + let (nw, nh) = (nw.clamp(0.0, MAX_SIDE), nh.clamp(0.0, MAX_SIDE)); + if (nw - w).abs() < EPS && (nh - h).abs() < EPS { + return None; + } + // The reference point (row-major from top-left) stays where it is. + let at = |lo: f64, size: f64, new: f64, k: u8| match k { + 0 => lo, + 1 => lo + (size - new) / 2.0, + _ => lo + size - new, + }; + let rp = o.auto_size_ref.min(8); + let x0 = at(r.x0, w, nw, rp % 3); + let y0 = at(r.y0, h, nh, rp / 3); + Some(Rect::new(x0, y0, x0 + nw, y0 + nh)) +} + +/// Give frame `fid` the inner bounds `to` (its path scaled into them). +fn resize(d: &mut Document, fid: ItemId, to: Rect) -> bool { + let Some(it) = d.item_mut(fid) else { return false }; + let r = it.inner_bounds(); + if r.width() < EPS || r.height() < EPS { + it.path = shapes::rectangle(to); + } else { + let xf = Affine::translate((to.x0, to.y0)) + * Affine::scale_non_uniform(to.width() / r.width(), to.height() / r.height()) + * Affine::translate((-r.x0, -r.y0)); + it.path.transform(xf); + } + true +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e..feb84b48 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -646,6 +646,9 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { }; // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); + if path.to_ascii_lowercase().ends_with(".idml") { + crate::autosize::apply(&mut d, None); + } let uid = s.doc()?.uid; let recovery_dir = s.recovery_dir.clone(); let index = s.active_index().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 96498e86..2a437091 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -157,6 +157,8 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { None => (None, 0, Vec::new()), }; warnings.extend(font_warnings); + // Auto-size frames fit their text as set here (with the document's fonts loaded). + crate::autosize::apply(&mut d, None); // Never save over the .idml with the native format: the document starts unsaved. let mut st = DocState::new(d, None); st.fonts = fonts; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be11..dac8504d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -514,7 +514,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -1478,6 +1478,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let rule_offset = ranged("columnRuleOffset", -1440.0, 1440.0, "a number of points")?; let rule_top = ranged("columnRuleTopInset", -1440.0, 1440.0, "a number of points")?; let rule_bottom = ranged("columnRuleBottomInset", -1440.0, 1440.0, "a number of points")?; + let min_height = ranged("minimumHeight", 0.0, crate::autosize::MAX_SIDE, "a number of points")?; + let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?; + let reference = match p.get("autoSizeReference") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(|n| crate::autosize::REFERENCES.iter().position(|r| *r == n)) + .ok_or_else(|| bad(ID, format!("autoSizeReference must be one of {}, not {v}", crate::autosize::REFERENCES.join("|"))))? + as u8, + ), + }; let rule_color = match p.get("columnRuleColor") { None | Some(Value::Null) => None, Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()), @@ -1517,6 +1528,24 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { }; } } + if let Some(v) = reference { + o.auto_size_ref = v; + } + if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { + o.auto_size_use_min_height = v; + } + if let Some(v) = min_height { + o.auto_size_min_height = v; + } + if let Some(v) = p.get("useMinimumWidth").and_then(Value::as_bool) { + o.auto_size_use_min_width = v; + } + if let Some(v) = min_width { + o.auto_size_min_width = v; + } + if let Some(v) = p.get("noLineBreaks").and_then(Value::as_bool) { + o.auto_size_no_line_breaks = v; + } if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) { o.ignore_wrap = v; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2165eebc..783ea0a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ #[cfg(not(target_arch = "wasm32"))] pub mod atomic; +pub mod autosize; pub mod cmd; pub mod dtd; pub mod guard; @@ -439,6 +440,22 @@ impl Session { st.doc = Arc::new(d); } } + // Auto-size frames fit the text the command changed, in the same undo step. + if spec.undoable + && let Some(st) = self.active_mut() + { + let prev = before.as_ref().filter(|(uid, _)| *uid == st.uid).map(|(_, d)| d.as_ref()); + if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) { + let todo = autosize::candidates(&st.doc, prev); + if !todo.is_empty() { + let mut d = (*st.doc).clone(); + if autosize::fit(&mut d, todo) { + st.doc = Arc::new(d); + st.revision += 1; + } + } + } + } // 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 @@ -556,6 +573,8 @@ pub(crate) use push_undo as record_undo; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_autosize; +#[cfg(test)] mod tests_idml; #[cfg(test)] mod tests_table; diff --git a/crates/engine/src/tests_autosize.rs b/crates/engine/src/tests_autosize.rs new file mode 100644 index 00000000..dfe76298 --- /dev/null +++ b/crates/engine/src/tests_autosize.rs @@ -0,0 +1,193 @@ +//! Text frame auto-size: frames fit their text after edits, in the same undo step, and on import. + +use designcraft_doc::{AutoSize, ItemId, StoryId}; +use designcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::Session; + +const LONG: &str = "Auto-sized frames grow as the text gets longer and shrink again when it is deleted, \ + so the frame always fits its text exactly, line after line after line."; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} + +/// A text frame with the caret in it and the given auto-size options: (frame, story). +fn frame(s: &mut Session, rect: [f64; 4], opts: Value) -> (ItemId, StoryId) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text"})).unwrap(); + let (id, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let mut p = opts; + p["ids"] = json!([id]); + s.execute("object.textFrameOptions", &p).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + (ItemId(id), StoryId(sid)) +} + +fn bounds(s: &Session, id: ItemId) -> Rect { + s.doc().unwrap().doc.item(id).unwrap().bounds() +} + +fn overset(s: &Session, sid: StoryId) -> bool { + designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).is_overset() +} + +fn clear(s: &mut Session, sid: StoryId) { + let len = s.doc().unwrap().doc.story(sid).unwrap().text.len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": len})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); +} + +#[test] +fn height_only_grows_and_shrinks_from_the_top() { + let mut s = session(); + let (id, sid) = frame(&mut s, [36.0, 36.0, 186.0, 56.0], json!({"autoSize": "heightOnly"})); + let empty = bounds(&s, id); + assert!(empty.height() > 5.0 && empty.height() < 20.0, "an empty frame keeps one line: {empty:?}"); + s.execute("text.insert", &json!({"text": LONG})).unwrap(); + let full = bounds(&s, id); + assert!(full.height() > 40.0, "{full:?}"); + assert!((full.y0 - 36.0).abs() < 1e-6 && (full.x0 - 36.0).abs() < 1e-6 && (full.width() - 150.0).abs() < 1e-6); + assert!(!overset(&s, sid)); + // Exact fit: a hair shorter and the last line no longer fits. + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + let ft = cs.frame(id).unwrap(); + assert!((ft.content_height - full.height()).abs() < 0.05, "{} vs {}", ft.content_height, full.height()); + clear(&mut s, sid); + let after = bounds(&s, id); + assert!((after.height() - empty.height()).abs() < 1e-6 && (after.y0 - 36.0).abs() < 1e-6, "{after:?}"); +} + +#[test] +fn bottom_reference_keeps_the_bottom_edge() { + let mut s = session(); + let (id, sid) = frame(&mut s, [36.0, 300.0, 186.0, 320.0], json!({"autoSize": "heightOnly", "autoSizeReference": "bottomLeft"})); + let y1 = bounds(&s, id).y1; + assert!((y1 - 320.0).abs() < 1e-6); + s.execute("text.insert", &json!({"text": LONG})).unwrap(); + let b = bounds(&s, id); + assert!((b.y1 - 320.0).abs() < 1e-6 && b.y0 < 290.0, "{b:?}"); + assert!(!overset(&s, sid)); +} + +#[test] +fn width_only_with_no_line_breaks_widens_to_the_longest_line() { + let mut s = session(); + let (id, sid) = frame(&mut s, [36.0, 36.0, 56.0, 120.0], json!({"autoSize": "widthOnly", "autoSizeReference": "topLeft", "noLineBreaks": true})); + s.execute("text.insert", &json!({"text": "Short\nA much longer second line"})).unwrap(); + let b = bounds(&s, id); + assert!(b.width() > 100.0 && (b.x0 - 36.0).abs() < 1e-6 && (b.height() - 84.0).abs() < 1e-6, "{b:?}"); + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + assert_eq!(cs.line_count(), 2, "each paragraph stays on one line"); + assert!(!cs.is_overset()); +} + +#[test] +fn minimum_height_is_respected() { + let mut s = session(); + let (id, sid) = frame(&mut s, [36.0, 36.0, 186.0, 56.0], json!({"autoSize": "heightOnly", "useMinimumHeight": true, "minimumHeight": 72})); + assert!((bounds(&s, id).height() - 72.0).abs() < 1e-6); + s.execute("text.insert", &json!({"text": format!("{LONG} {LONG} {LONG}")})).unwrap(); + assert!(bounds(&s, id).height() > 72.0); + clear(&mut s, sid); + assert!((bounds(&s, id).height() - 72.0).abs() < 1e-6); +} + +#[test] +fn one_undo_restores_text_and_size() { + let mut s = session(); + let (id, sid) = frame(&mut s, [36.0, 36.0, 186.0, 56.0], json!({"autoSize": "heightOnly"})); + let before = bounds(&s, id); + s.execute("text.insert", &json!({"text": LONG})).unwrap(); + assert!(bounds(&s, id).height() > before.height()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, ""); + assert_eq!(bounds(&s, id), before); +} + +#[test] +fn rotated_frame_keeps_its_reference_point_in_place() { + let mut s = session(); + let (id, _) = frame(&mut s, [100.0, 100.0, 250.0, 120.0], json!({"autoSize": "heightOnly", "autoSizeReference": "topLeft"})); + s.execute("transform.rotate", &json!({"angle": 30, "ids": [id.0]})).unwrap(); + let corner = |s: &Session| { + let it = s.doc().unwrap().doc.item(id).unwrap().clone(); + let r = it.inner_bounds(); + (it.xf * Point::new(r.x0, r.y0), r) + }; + let (p0, r0) = corner(&s); + s.execute("text.insert", &json!({"text": LONG})).unwrap(); + let (p1, r1) = corner(&s); + assert!(r1.height() > r0.height() + 20.0); + assert!((p1 - p0).hypot() < 1e-6, "{p0:?} → {p1:?}"); +} + +#[test] +fn a_threaded_frame_before_the_last_does_not_auto_size() { + let mut s = session(); + let (a, sid) = frame(&mut s, [36.0, 36.0, 186.0, 56.0], json!({"autoSize": "heightOnly"})); + let b = s.execute("frame.create", &json!({"rect": [36.0, 200.0, 186.0, 260.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| Ok(d.thread(a, ItemId(b))?)).unwrap(); + // Setting it up resized `a` while it was alone; from now on it keeps its size. + let h = bounds(&s, a).height(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": format!("{LONG} {LONG}")})).unwrap(); + assert!((bounds(&s, a).height() - h).abs() < 1e-6); +} + +#[test] +fn idml_import_fits_height_only_frames() { + // A 36 pt label in a frame 0.8 × its size tall: overset until auto-size grows it. + let mut s = session(); + let r = s.execute("frame.create", &json!({"rect": [36.0, 36.0, 300.0, 64.8], "content": "text"})).unwrap(); + let (id, sid) = (ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap())); + let orig = bounds(&s, id); + s.execute("text.insert", &json!({"text": "Brand Bible"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 36}})).unwrap(); + // Set the option without running a command, as a file written elsewhere would have it. + s.edit(|d, _| { + if let Some(tf) = d.item_mut(id).and_then(designcraft_doc::Item::text_frame_mut) { + tf.options.auto_size = AutoSize::HeightOnly; + } + Ok(()) + }) + .unwrap(); + assert!(overset(&s, sid)); + let idml = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].clone(); + s.execute("file.openIdml", &json!({"base64": idml, "name": "labels.idml"})).unwrap(); + let d = &s.doc().unwrap().doc; + let (fid, story) = d.stories.values().find(|st| st.text == "Brand Bible").map(|st| (st.frames[0], st.id)).unwrap(); + let cs = designcraft_compose::compose_story(d, story, &Default::default()); + assert!(!cs.is_overset(), "the label shows after import"); + let b = d.item(fid).unwrap().bounds(); + assert!(b.height() > 36.0 && (b.y0 - orig.y0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn idml_keeps_the_auto_size_options() { + let mut s = session(); + let opts = json!({"autoSize": "heightAndWidth", "autoSizeReference": "bottomRight", "useMinimumHeight": true, "minimumHeight": 30, + "useMinimumWidth": true, "minimumWidth": 40, "noLineBreaks": true}); + let (id, _) = frame(&mut s, [36.0, 36.0, 186.0, 56.0], opts); + let d = &s.doc().unwrap().doc; + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap(); + let options = |d: &designcraft_doc::Document, id: ItemId| d.item(id).and_then(|i| i.text_frame()).map(|t| t.options.clone()); + let a = options(d, id).unwrap(); + let b = back.stories.values().next().and_then(|st| options(&back, st.frames[0])).unwrap(); + assert_eq!(a.auto_size_ref, 8); + assert_eq!( + ( + b.auto_size, + b.auto_size_ref, + b.auto_size_use_min_height, + b.auto_size_min_height, + b.auto_size_use_min_width, + b.auto_size_min_width, + b.auto_size_no_line_breaks + ), + (a.auto_size, a.auto_size_ref, true, 30.0, true, 40.0, true) + ); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b..7a8e2675 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2485,7 +2485,12 @@ fn text_frame_pref(o: &TextFrameOptions, rule_color: &str) -> El { .attr("VerticalThreshold", num(o.vj_paragraph_spacing_limit)) .attr("IgnoreWrap", bool_s(o.ignore_wrap)) .attr("AutoSizingType", names::auto_size_out(o.auto_size)) - .attr("AutoSizingReferencePoint", names::REF_POINTS[(o.auto_size_ref as usize).min(8)]); + .attr("AutoSizingReferencePoint", names::REF_POINTS[(o.auto_size_ref as usize).min(8)]) + .attr("UseMinimumHeightForAutoSizing", bool_s(o.auto_size_use_min_height)) + .attr("MinimumHeightForAutoSizing", num(o.auto_size_min_height)) + .attr("UseMinimumWidthForAutoSizing", bool_s(o.auto_size_use_min_width)) + .attr("MinimumWidthForAutoSizing", num(o.auto_size_min_width)) + .attr("UseNoLineBreaksForAutoSizing", bool_s(o.auto_size_no_line_breaks)); let el = if o.column_width > 0.0 { el.attr("TextColumnFixedWidth", num(o.column_width)) } else { el }; let d = TextFrameOptions::default(); let rule_set = o.column_rule diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b..e5dd4d74 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2551,6 +2551,12 @@ fn text_frame_options(e: &El) -> TextFrameOptions { if let Some(v) = e.get("AutoSizingReferencePoint") { o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8; } + let size = |k: &str| e.num(k).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)); + o.auto_size_use_min_height = e.get("UseMinimumHeightForAutoSizing") == Some("true"); + o.auto_size_min_height = size("MinimumHeightForAutoSizing").unwrap_or(0.0); + o.auto_size_use_min_width = e.get("UseMinimumWidthForAutoSizing") == Some("true"); + o.auto_size_min_width = size("MinimumWidthForAutoSizing").unwrap_or(0.0); + o.auto_size_no_line_breaks = e.get("UseNoLineBreaksForAutoSizing") == Some("true"); // Column rules (the colour is a swatch reference: see `Importer::frame_options`). o.column_rule = e.get("ColumnRuleOverride") == Some("true"); let finite = |k: &str| e.num(k).filter(|v| v.is_finite()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e..0f296467 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -433,6 +433,11 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, "columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset), "columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset), + "autoSize": serde_json::to_value(o.auto_size).unwrap_or(Value::Null), + "autoSizeReference": designcraft_engine::autosize::REFERENCES.get(usize::from(o.auto_size_ref)).copied().unwrap_or("topCenter"), + "useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height), + "useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width), + "noLineBreaks": o.auto_size_no_line_breaks, }); if let Value::Object(m) = &shown { for (k, v) in m { @@ -515,6 +520,57 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ui.end_row(); } }); + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(tr("Auto-Size")).font(semibold(12.0))); + egui::Grid::new("tfo_autosize").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Auto-Sizing")); + translated_combo( + &lang, + ui, + d, + "autoSize", + &[ + ("off", "Off"), + ("heightOnly", "Height Only"), + ("widthOnly", "Width Only"), + ("heightAndWidth", "Height and Width"), + ("heightAndWidthProportionally", "Height and Width (Keep Proportions)"), + ], + ); + ui.end_row(); + crate::rtl::label(ui, tr("Reference Point")); + reference_point(ui, d, "autoSizeReference"); + ui.end_row(); + check(ui, d, "useMinimumHeight", tr("Minimum Height")); + text_field(ui, d, "minimumHeight", 70.0); + ui.end_row(); + check(ui, d, "useMinimumWidth", tr("Minimum Width")); + text_field(ui, d, "minimumWidth", 70.0); + ui.end_row(); + }); + check(ui, d, "noLineBreaks", tr("No Line Breaks")); +} + +/// A 3×3 reference point picker; `d[key]` holds one of `AUTO_SIZE_REFERENCES`. +fn reference_point(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let t = crate::theme::Tokens::get(ui.ctx()); + let cur = d.s(key); + let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + ui.painter().rect_stroke(r.shrink(5.0), 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + for (i, name) in designcraft_engine::autosize::REFERENCES.iter().enumerate() { + let c = r.min + egui::vec2(5.0 + 18.0 * (i % 3) as f32, 5.0 + 18.0 * (i / 3) as f32); + let dot = egui::Rect::from_center_size(c, egui::vec2(8.0, 8.0)); + let resp = ui.interact(dot.expand(2.0), ui.id().with((key, i)), egui::Sense::click()); + if resp.clicked() { + d.fields.insert(key.into(), json!(name)); + } + if cur == *name { + ui.painter().rect_filled(dot, 0.0, t.accent); + } else { + ui.painter().rect_filled(dot, 0.0, t.panel); + ui.painter().rect_stroke(dot, 0.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.icon }), egui::StrokeKind::Inside); + } + } } /// Whether the user changed `key` from what the dialog showed. @@ -575,6 +631,21 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad(what))?)); + } + } if p.is_empty() { return Ok(Value::Null); } diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7da..66f7f873 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -71,7 +71,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · black-box, results kept under `plan/`) and by DesignCraft, comparing line breaks, overset and frame fit. Known differences: optical kerning falls back to metrics, so imported text sets wider (#181); a frame goes overset when descenders don't fit, InDesign needs only the baseline - (#180); setting the text language was missing (#130; Language is now in Properties, #145, the issue is still open); "hyphenation doesn't work" + (#180; auto-size frames fit their text on import); setting the text language was missing (#130; Language is now in Properties, #145, the issue is still open); "hyphenation doesn't work" (#87). - **Impact:** an opened IDML reflows: different line breaks, different page count, overset text. Professionals notice on the first page. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9095faf5..bf40d167 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -337,6 +337,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). - Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. +- Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step, and after IDML import; it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.