mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -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:
@@ -152,6 +152,7 @@ Newest first.
|
||||
- **2026-10-12** · Clipping paths saved with images in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path, inverted or not) clip the image on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (#450). No readiness percentage change.
|
||||
- **2026-10-12** · Anchored objects with text wrap push text aside: other stories' text under them and the lines of their own story after the anchor's line (#557). No readiness percentage change.
|
||||
- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change.
|
||||
- **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** · Windows: dragging the window across monitors with different display scales keeps its size instead of growing until it crashes (winit pinned to its 0.30.x backport of the fix). The GPU surface may be as large as the adapter's largest texture (16384 px on DirectX 12, Vulkan and Metal hardware) instead of 8192, and a wgpu error is logged instead of closing the app. 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.
|
||||
|
||||
@@ -370,6 +370,19 @@ pub struct TextFrameOptions {
|
||||
pub auto_size: AutoSize,
|
||||
/// Reference point (0..9, row-major from the 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,
|
||||
@@ -426,6 +439,11 @@ impl Default for TextFrameOptions {
|
||||
ignore_wrap: false,
|
||||
auto_size: AutoSize::Off,
|
||||
auto_size_ref: 4,
|
||||
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(),
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -2784,7 +2784,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 };
|
||||
// Written on every frame: on import a frame takes any value it lacks from its object style.
|
||||
let el = el
|
||||
|
||||
@@ -3136,6 +3136,12 @@ fn text_frame_options(prefs: &[&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());
|
||||
|
||||
@@ -437,6 +437,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 {
|
||||
@@ -519,6 +524,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.
|
||||
@@ -579,6 +635,21 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
|
||||
p.insert(key.into(), json!(d.m(key).ok_or_else(|| bad(what))?));
|
||||
}
|
||||
}
|
||||
for key in ["autoSize", "autoSizeReference"] {
|
||||
if edited(d, key) {
|
||||
p.insert(key.into(), json!(d.s(key)));
|
||||
}
|
||||
}
|
||||
for key in ["useMinimumHeight", "useMinimumWidth", "noLineBreaks"] {
|
||||
if edited(d, key) {
|
||||
p.insert(key.into(), json!(d.b(key)));
|
||||
}
|
||||
}
|
||||
for (key, what) in [("minimumHeight", "the minimum height is not a measure"), ("minimumWidth", "the minimum width is not a measure")] {
|
||||
if edited(d, key) {
|
||||
p.insert(key.into(), json!(d.m(key).filter(|v| *v >= 0.0).ok_or_else(|| bad(what))?));
|
||||
}
|
||||
}
|
||||
if p.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
|
||||
+1
-1
@@ -65,7 +65,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 is an outline-based approximation, not Adobe's
|
||||
algorithm (#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.
|
||||
|
||||
@@ -340,6 +340,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. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None).
|
||||
|
||||
Reference in New Issue
Block a user