Merge #180: text frame auto-size grows and shrinks frames to fit their text

text-autosize 60551322.

Conflict: crates/doc/src/item.rs: next's reference-point doc and default auto_size_ref 4 (centre, as IDML leaves it) plus the branch's minimum-size and No Line Breaks fields
Conflict: crates/engine/src/lib.rs: autosize pass before next's record_undo_step on the success path; bump_revision for next's global revisions
Conflict: crates/engine/src/cmd/interchange.rs: next's grep_style_warnings and .indd comment plus the branch's autosize::apply on import
Conflict: crates/engine/src/cmd/object.rs: branch's textFrameOptions params doc with next's firstBaseline values
Conflict: ROADMAP.md: progress log both
Conflict: docs/gaps.md: next's optical kerning wording with the branch's auto-size note on #180
This commit is contained in:
aiongg
2026-10-12 19:57:02 +00:00
13 changed files with 597 additions and 3 deletions
+246
View File
@@ -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<Item>], 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<Rect> {
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<Measure> {
let mut specs: Vec<FrameSpec> = 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<f64> {
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<f64> { 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
}
+3
View File
@@ -814,6 +814,9 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
};
// 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)?;
+2
View File
@@ -247,6 +247,8 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
};
warnings.extend(font_warnings);
warnings.extend(super::style::grep_style_warnings(&d));
// 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 or .indd with the native format: the document starts unsaved.
let mut st = DocState::new(d, None);
st.fonts = fonts;
+30 -1
View File
@@ -541,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, 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?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, 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
),
@@ -2022,6 +2022,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
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()),
@@ -2061,6 +2072,24 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
};
}
}
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;
}
+19
View File
@@ -9,6 +9,7 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod atomic;
pub mod autosize;
pub mod cmd;
pub mod dtd;
pub mod guard;
@@ -521,6 +522,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.bump_revision();
}
}
}
}
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
@@ -644,6 +661,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_ime;
+193
View File
@@ -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)
);
}