Merge pull request #2647 from dotCooCoo/feat/non-rectangular-frame-inset

Text Frame Options: a non-rectangular frame shows one Inset field
This commit is contained in:
FredApps authored and GitHub committed 2026-11-12 18:00:15 +01:00
commit b70472bc52
16 files changed
+201 -13

No files matched your search

+57 -13
View File
@@ -1005,6 +1005,28 @@ fn selected_frame_options(app: &DesignApp) -> Option<designcraft_doc::TextFrameO
st.doc.item(fid)?.text_frame().map(|t| t.options.clone())
}
/// Whether every selected text frame (the selected items, or the frame of the caret) has an
/// outline that is not its own axis-aligned rectangle: an oval, a polygon, rounded or other corner
/// options. Its text keeps one inset from the outline, as compose sets it; type on a path doesn't.
/// A selection that mixes them with rectangular frames shows the four sides, so each frame keeps
/// the sides left as shown.
fn selected_frame_is_shaped(app: &DesignApp) -> bool {
let Some(st) = app.session.active() else { return false };
let shaped = |fid: designcraft_doc::ItemId| -> Option<bool> {
let item = st.doc.item(fid)?;
if item.text_frame()?.options.path.is_some() {
return Some(false);
}
let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) };
Some(designcraft_compose::FrameOutline::new(&bp, 0.0).is_some())
};
if st.selection.items.is_empty() {
let Some(fid) = st.selection.text.and_then(|t| t.frame) else { return false };
return shaped(fid).unwrap_or(false);
}
st.selection.items.iter().all(|&fid| shaped(fid).unwrap_or(false))
}
/// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed,
/// so OK sends only the fields the user changed. Fields given when the dialog opened count as
/// changed.
@@ -1032,7 +1054,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
});
let shown = json!({
let mut shown = json!({
"columns": o.columns, "gutter": fm(o.gutter),
"columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width),
"balanceColumns": o.balance_columns, "ignoreWrap": o.ignore_wrap,
@@ -1051,6 +1073,12 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
"customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment),
"gridRelativeTo": grid.relative_to.name(),
});
// A non-rectangular frame shows one Inset (its top inset, the one kept from the outline).
let shaped = selected_frame_is_shaped(app);
if shaped && let Value::Object(m) = &mut shown {
m.insert("inset".into(), fm(o.inset[0]));
}
d.fields.insert("_shaped".into(), json!(shaped));
if let Value::Object(m) = &shown {
for (k, v) in m {
d.fields.entry(k.clone()).or_insert_with(|| v.clone());
@@ -1078,9 +1106,11 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
}
ui.set_min_width(560.0);
ui.horizontal_top(|ui| {
// A fixed height: the separator would otherwise stretch the dialog to the window.
ui.set_min_height(330.0);
ui.set_max_height(330.0);
// A fixed height: the separator would otherwise stretch the dialog to the window. A
// non-rectangular frame's Inset row needs one more row.
let height = if d.b("_shaped") { 360.0 } else { 330.0 };
ui.set_min_height(height);
ui.set_max_height(height);
section_list(
&lang,
ui,
@@ -1218,8 +1248,9 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) {
}
/// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and
/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right), Vertical
/// Justification (align, paragraph spacing limit) and Ignore Text Wrap.
/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right; one Inset
/// for a non-rectangular frame), Vertical Justification (align, paragraph spacing limit) and
/// Ignore Text Wrap.
fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
let tr = |s: &'static str| crate::i18n::tr(lang, s);
let (cap, field, cap2) = (64.0, 110.0, 70.0);
@@ -1243,8 +1274,10 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
// Fixed Number: each column's width, from the frame, the insets and the gutter.
let frame = d.fields.get("_frameWidth").and_then(Value::as_f64);
let n = d.n("columns").unwrap_or(1.0).max(1.0);
// A non-rectangular frame keeps its sides until its one Inset is changed.
let (left, right) = if d.b("_shaped") && edited(d, "inset") { ("inset", "inset") } else { ("insetLeft", "insetRight") };
let width = frame.map(|w| {
let inner = w - d.m("insetLeft").unwrap_or(0.0) - d.m("insetRight").unwrap_or(0.0);
let inner = w - d.m(left).unwrap_or(0.0) - d.m(right).unwrap_or(0.0);
((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0)
});
let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui);
@@ -1262,6 +1295,9 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
check(ui, d, "balanceColumns", tr("Balance Columns"));
});
framed(ui, Some(tr("Inset Spacing")), |ui| {
// A non-rectangular frame: the four sides are dimmed and one Inset sets the distance kept
// from the outline.
let shaped = d.b("_shaped");
// While linked, a side typed in sets all four ("Make all settings the same").
let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"];
let before: Vec<String> = sides.iter().map(|k| d.s(k)).collect();
@@ -1270,24 +1306,32 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
{
ui.horizontal(|ui| {
caption_at(ui, tr(l1), cap);
spin_measure(ui, d, k1, field, true);
spin_measure(ui, d, k1, field, !shaped);
ui.add_space(8.0);
if r == 0 {
let linked = d.b("insetLinked");
let tip = tr("Make all settings the same");
if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0))
.clicked()
{
let toggle = ui.add_enabled_ui(!shaped, |ui| {
crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0))
});
if toggle.inner.clicked() {
d.fields.insert("insetLinked".into(), json!(!linked));
}
} else {
ui.add_space(22.0 + ui.spacing().item_spacing.x);
}
caption_at(ui, tr(l2), cap2 - 30.0);
spin_measure(ui, d, k2, field, true);
spin_measure(ui, d, k2, field, !shaped);
});
}
if d.b("insetLinked")
if shaped {
ui.horizontal(|ui| {
caption_at(ui, tr("Inset:"), cap);
spin_measure(ui, d, "inset", field, true);
});
}
if !shaped
&& d.b("insetLinked")
&& let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k)
{
let v = d.fields.get(k).cloned().unwrap_or_default();
+130
View File
@@ -1559,6 +1559,136 @@ fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
assert_eq!(grid(&app), None);
}
/// A non-rectangular frame (an oval, or a rectangle with rounded corners) shows one Inset, the
/// distance kept from its outline, and OK sends it as one value for all four sides. A rectangle
/// keeps the four side fields.
#[test]
fn text_frame_options_set_one_inset_for_a_non_rectangular_frame() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let oval = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap();
let oval = designcraft_doc::ItemId(oval["id"].as_u64().unwrap());
let rect = app.run("frame.create", json!({"rect": [36, 300, 336, 500], "content": "text", "text": "Hello"})).unwrap();
let rect = designcraft_doc::ItemId(rect["id"].as_u64().unwrap());
app.run("object.textFrameOptions", json!({"ids": [oval.0, rect.0], "inset": [6, 2, 3, 4]})).unwrap();
let opts = |app: &DesignApp, id| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone();
let open = |app: &mut DesignApp, id: designcraft_doc::ItemId| {
app.run("selection.set", json!({"ids": [id.0]})).unwrap();
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(app, &mut d);
d
};
// The oval shows its top inset as the one Inset.
let d = open(&mut app, oval);
assert!(d.b("_shaped"));
assert_eq!(d.s("inset"), "0p6");
// OK without edits changes nothing, and adds no undo step.
let undo = app.session.doc().unwrap().history.undo.len();
app.ui.dialog = Some(d);
assert_eq!(confirm(&mut app).unwrap(), Value::Null);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
// Inset set to 1p0: all four sides take it.
let mut d = open(&mut app, oval);
d.fields.insert("inset".into(), json!("1p0"));
app.ui.dialog = Some(d);
confirm(&mut app).unwrap();
assert_eq!(opts(&app, oval).inset, [12.0; 4]);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// A value that isn't a measure is refused and the frame keeps its inset.
let mut d = open(&mut app, oval);
d.fields.insert("inset".into(), json!("not a measure"));
app.ui.dialog = Some(d);
assert!(confirm(&mut app).unwrap_err().to_string().contains("the inset is not a measure"));
assert_eq!(opts(&app, oval).inset, [12.0; 4]);
// A rectangle shows the four sides; with rounded corners it is not rectangular either.
let d = open(&mut app, rect);
assert!(!d.b("_shaped") && !d.fields.contains_key("inset"), "a rectangle shows the four sides");
app.run("object.cornerOptions", json!({"ids": [rect.0], "shape": "rounded", "size": 12})).unwrap();
let d = open(&mut app, rect);
assert!(d.b("_shaped"));
assert_eq!(d.s("inset"), "0p6");
}
/// A selection that mixes a non-rectangular frame with a rectangle shows the four sides, even with
/// the oval first, and a changed side leaves the rectangle's other sides as they were.
#[test]
fn text_frame_options_show_four_sides_for_a_mixed_selection() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let oval = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap();
let oval = designcraft_doc::ItemId(oval["id"].as_u64().unwrap());
let rect = app.run("frame.create", json!({"rect": [36, 300, 336, 500], "content": "text", "text": "Hello"})).unwrap();
let rect = designcraft_doc::ItemId(rect["id"].as_u64().unwrap());
app.run("object.textFrameOptions", json!({"ids": [oval.0, rect.0], "inset": [6, 2, 3, 4]})).unwrap();
let opts = |app: &DesignApp, id| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone();
app.run("selection.set", json!({"ids": [oval.0, rect.0]})).unwrap();
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
assert!(!d.b("_shaped") && !d.fields.contains_key("inset"), "a mixed selection shows the four sides");
d.fields.insert("insetTop".into(), json!("1p0"));
app.ui.dialog = Some(d);
confirm(&mut app).unwrap();
assert_eq!(opts(&app, rect).inset, [12.0, 2.0, 3.0, 4.0]);
assert_eq!(opts(&app, oval).inset, [12.0, 2.0, 3.0, 4.0]);
// Two non-rectangular frames still show the one Inset.
app.run("object.cornerOptions", json!({"ids": [rect.0], "shape": "rounded", "size": 12})).unwrap();
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
assert!(d.b("_shaped"));
}
/// The dialog dims Top, Left, Bottom, Right and the link for a non-rectangular frame and adds an
/// Inset field; for a rectangle it shows no Inset field and the sides stay editable.
#[test]
fn text_frame_options_dims_the_sides_for_a_non_rectangular_frame() {
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
for shaped in [true, false] {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let shape = if shaped { "ellipse" } else { "rectangle" };
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": shape, "content": "text", "text": "Hello"})).unwrap();
app.run("selection.set", json!({"ids": [r["id"]]})).unwrap();
app.ui.dialog = Some(Dialog::new("textFrameOptions", json!({})));
// The General tab's Columns and Align dropdowns have no accessible name yet.
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_accessibility_check(false).build_ui_state(
|ui, app: &mut DesignApp| {
let key = egui::Id::unique("text_frame_inset_test_fonts");
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(key, true));
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(3);
for side in ["insetTop", "insetLeft", "insetBottom", "insetRight"] {
let label = format!("Increase tfo_{side}");
assert_eq!(h.get_by_label(&label).accesskit_node().is_disabled(), shaped, "{shape}: {side}");
}
assert_eq!(h.query_by_label("Inset:").is_some(), shaped, "{shape}: the Inset caption");
assert_eq!(h.query_by_label("Increase tfo_inset").is_some(), shaped, "{shape}: the Inset field");
// The General tab fits in the dialog's fixed height: the buttons stay put on another tab.
let (wrap, cancel) = (h.get_by_label("Ignore Text Wrap").rect().bottom(), h.get_by_label("Cancel").rect().top());
assert!(wrap < cancel, "{shape}: Ignore Text Wrap ends at {wrap}, below the top of Cancel at {cancel}");
h.get_by_label("Auto-Size").click();
h.run_steps(3);
assert_eq!(h.get_by_label("Cancel").rect().top(), cancel, "{shape}: the buttons stay put on another tab");
h.get_by_label("General").click();
h.run_steps(3);
// The link toggles only for a rectangle.
let linked = |h: &Harness<'_, DesignApp>| h.state().ui.dialog.as_ref().unwrap().b("insetLinked");
let before = linked(&h);
h.get_by_label("Make all settings the same").click();
h.run_steps(3);
assert_eq!(linked(&h) != before, !shaped, "{shape}: the link");
}
}
#[test]
fn text_frame_options_change_only_what_was_edited() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+1
View File
@@ -1861,6 +1861,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Keep Options", "خيارات الإبقاء"),
("Span Columns", "امتداد الأعمدة"),
("Top:", "أعلى:"),
("Inset:", "الإزاحة الداخلية:"),
("Left:", "يسار:"),
("Bottom:", "أسفل:"),
("Right:", "يمين:"),
+1
View File
@@ -1916,6 +1916,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Third Space", "Třetina čtverčíku"),
("Title Case", "Velká Počáteční Písmena"),
("Top:", "Nahoře:"),
("Inset:", "Odsazení:"),
("Trademark Symbol", "Symbol ochranné známky"),
("Transform Again Individually", "Transformovat znovu jednotlivě"),
("Transform Sequence Again", "Transformovat posloupnost znovu"),
+1
View File
@@ -1866,6 +1866,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Third Space", "Drittelgeviert"),
("Title Case", "Wortanfänge groß"),
("Top:", "Oben:"),
("Inset:", "Versatz:"),
("Trademark Symbol", "Markensymbol"),
("Transform Again Individually", "Erneut einzeln transformieren"),
("Transform Sequence Again", "Abfolge erneut transformieren"),
+1
View File
@@ -1730,6 +1730,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Top Margin", "Felső margó"),
("Top of Page", "Oldal teteje"),
("Top:", "Fent:"),
("Inset:", "Belső margó:"),
("Track Changes", "Változások követése"),
("Track Changes in All Stories", "Változások követése minden szövegfolyamban"),
("Tracking", "Betűköz"),
+1
View File
@@ -1921,6 +1921,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Keep Options", "Opzioni di unione"),
("Span Columns", "Estendi su colonne"),
("Top:", "Superiore:"),
("Inset:", "Rientro:"),
("Left:", "Sinistra:"),
("Bottom:", "Inferiore:"),
("Right:", "Destra:"),
+1
View File
@@ -2059,6 +2059,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Third Space", "三分スペース"),
("Title Case", "各単語の先頭を大文字に"),
("Top:", "上:"),
("Inset:", "フレーム内マージン:"),
("Trademark Symbol", "商標記号"),
("Transform Again Individually", "個別に再変形"),
("Transform Sequence Again", "一連の変形を再実行"),
+1
View File
@@ -1957,6 +1957,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Third Space", "მესამედი შორისი"),
("Title Case", "ყოველი სიტყვა მთავრულით"),
("Top:", "ზემოთ:"),
("Inset:", "შეწევა:"),
("Trademark Symbol", "სავაჭრო ნიშნის სიმბოლო"),
("Transform Again Individually", "გარდაქმნის ცალ-ცალკე გამეორება"),
("Transform Sequence Again", "გარდაქმნების მიმდევრობის გამეორება"),
+1
View File
@@ -1891,6 +1891,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Third Space", "Trzecia firetu"),
("Title Case", "Wielkie Litery Wyrazów"),
("Top:", "Góra:"),
("Inset:", "Margines wewnętrzny:"),
("Trademark Symbol", "Symbol znaku towarowego"),
("Transform Again Individually", "Przekształć ponownie osobno"),
("Transform Sequence Again", "Powtórz sekwencję przekształceń"),
+1
View File
@@ -1468,6 +1468,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Keep Options", "Opções de manter"),
("Span Columns", "Abranger colunas"),
("Top:", "Superior:"),
("Inset:", "Recuo:"),
("Left:", "Esquerda:"),
("Bottom:", "Inferior:"),
("Right:", "Direita:"),
+1
View File
@@ -1901,6 +1901,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Third Space", "Треть шпации"),
("Title Case", "Каждое слово с прописной"),
("Top:", "Сверху:"),
("Inset:", "Отступ:"),
("Trademark Symbol", "Знак товарного знака"),
("Transform Again Individually", "Повторить трансформирование по отдельности"),
("Transform Sequence Again", "Повторить последовательность трансформирования"),
+1
View File
@@ -65,6 +65,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Gutter:", "Spaltmellanrum:"),
("Fit Guides to:", "Anpassa stödlinjer till:"),
("Top:", "Överkant:"),
("Inset:", "Infällning:"),
("Bottom:", "Nederkant:"),
("Inside:", "Inre:"),
("Outside:", "Yttre:"),
+1
View File
@@ -479,6 +479,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
("Gutter:", ["Steg:", "Gouttière :", "Medianil:", "間隔:", "栏间距:"]),
("Fit Guides to:", ["Hilfslinien einpassen in:", "Ajuster les repères à :", "Ajustar guías a:", "ガイドの適用先:", "参考线适合:"]),
("Top:", ["Oben:", "Haut :", "Superior:", "天:", "上:"]),
("Inset:", ["Versatz:", "Marge intérieure :", "Margen interior:", "フレーム内マージン :", "内边距:"]),
("Bottom:", ["Unten:", "Bas :", "Inferior:", "地:", "下:"]),
("Inside:", ["Innen:", "Intérieur :", "Interior:", "ノド:", "内:"]),
("Outside:", ["Außen:", "Extérieur :", "Exterior:", "小口:", "外:"]),
+1
View File
@@ -1888,6 +1888,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Keep Options", "Параметри утримання"),
("Span Columns", "Охоплення колонок"),
("Top:", "Зверху:"),
("Inset:", "Відступ:"),
("Left:", "Ліворуч:"),
("Bottom:", "Знизу:"),
("Right:", "Праворуч:"),
+1
View File
@@ -353,6 +353,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- 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 (opening a file keeps the frame sizes it gives); 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.
- Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally.
- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines.
- Text Frame Options › Inset Spacing for a non-rectangular frame (an oval, a polygon, a frame with corner options; when every selected text frame is one, a mixed selection keeps the four sides): Top, Left, Bottom, Right and the link are dimmed, and one Inset field sets the distance kept between the text and the frame's outline (`object.textFrameOptions {inset: <number>}`, all four sides).
- 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).