mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user