Text frames: Balance Columns evens out the last frame's columns (#609)

In the frame a story ends in, Balance Columns lowers the bottom of the
columns after the last explicit column break as far as the text still
fits, reusing the bounded search that balances columns above spanning
paragraphs. Frames the text runs on from fill normally. Text Frame
Options gets a Balance Columns checkbox.
This commit is contained in:
aiongg
2026-10-12 19:18:45 +00:00
parent c3859c828b
commit 30ba47211e
8 changed files with 247 additions and 10 deletions
+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-12** · Text Frame Options › Balance Columns (`object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`) evens out the columns of the frame a story ends in, from its last column break on; frames the text runs on from fill normally (#609). No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
+83 -8
View File
@@ -535,7 +535,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut limits: Rc<Vec<(BandKey, f64)>> = Rc::default();
let mut trial: Option<Trial> = None;
let mut balance_runs = 0usize;
let balance_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count();
let balance_frames = frames.iter().zip(&cols).filter(|(f, c)| f.opts.balance_columns && c.len() > 1).count();
let balance_budget = 20
* (story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count()
+ usize::from(balance_frames > 0));
// Balancing split-column blocks that text follows.
let mut split_limits: Rc<Vec<(SplitKey, f64)>> = Rc::default();
let mut split_trial: Option<Trial<SplitKey>> = None;
@@ -545,8 +548,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// and in split columns.
let mut span_paras = vec![false; np];
let mut split_paras = vec![false; np];
'paras: while pi < np {
let prange = para_ranges[pi].clone();
'paras: while pi <= np {
cur.pi = pi;
cur.limits = Rc::clone(&limits);
cur.split_limits = Rc::clone(&split_limits);
@@ -565,6 +567,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
pi = j;
continue 'paras;
}
if pi >= np {
// The story ends here: Balance Columns evens out the columns of its last frame.
if balance_frames > 0
&& let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
pi = j;
continue 'paras;
}
break 'paras;
}
let prange = para_ranges[pi].clone();
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
// A break character that ends the previous paragraph sends this one on, like a start option.
@@ -792,7 +807,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// A paragraph start or a break character; Next Column while a paragraph spans columns is
// the next frame, as above.
let begin_at = |cur: &mut Cursor, start: StartParagraph| match start {
StartParagraph::NextColumn => next_column(cur),
StartParagraph::NextColumn => {
let (fi, col) = (cur.fi, cur.col);
next_column(cur);
if cur.fi == fi && cur.col != col {
cur.band.tail = Some((cur.pi, cur.col));
}
}
other => cur.start(other, &cols, frames, doc),
};
// Paragraph start options.
@@ -1132,7 +1153,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
reference: cur.last_reference,
pending: pp.space_after,
};
cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) };
cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None };
}
// Rules and shading for the paragraph.
if let Some((fi, bl, asc, _)) = first_line_rect {
@@ -1820,6 +1841,9 @@ struct Band {
line0: usize,
/// Below a spanning paragraph, its last line: every column of the band continues from it.
above: Option<Above>,
/// After the band's last explicit column break: the paragraph after it and the column it
/// starts. Balance Columns evens out only the columns from there on.
tail: Option<(usize, usize)>,
}
#[derive(Clone, Copy, Debug)]
@@ -1878,6 +1902,54 @@ fn trial_failed<K: TrialKey>(trial: &mut Option<Trial<K>>, limits: &mut Rc<Vec<(
Some(key.para())
}
/// At the end of the story, Balance Columns in the frame the text ends in: lowers the bottom of
/// the frame's last band of columns (from its last explicit column break on) as far as the text
/// still fits the frame, so the columns end at about the same depth. Returns the paragraph to
/// re-lay from while the search goes on; None once the layout is final.
#[allow(clippy::too_many_arguments)]
fn balance_end(
trial: &mut Option<Trial>,
limits: &mut Rc<Vec<(BandKey, f64)>>,
cur: &Cursor,
cols: &[Vec<Rect>],
frames: &[FrameSpec],
out: &ComposedStory,
np: usize,
budget_left: bool,
) -> Option<usize> {
let fi = cur.fi;
let (para, col0) = cur.band.tail.unwrap_or((cur.band.para, 0));
let key = (fi, para);
let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == np);
if !active && let Some(t) = trial.take() {
set_limit(limits, t.key, None);
}
let columns = cols.get(fi)?;
if !frames.get(fi)?.opts.balance_columns || col0.saturating_add(1) >= columns.len() {
return None;
}
let lines: Vec<&Line> =
out.frames.get(fi)?.lines.get(cur.band.line0..).unwrap_or_default().iter().filter(|l| l.column as usize >= col0).collect();
let bottom = lines.iter().map(|l| l.baseline + l.descent).filter(|b| b.is_finite()).max_by(f64::total_cmp)?;
if lines.len() < 2 || !(active || limit_of(limits, key).is_none() && budget_left) {
return None;
}
let top = cur.clip(*columns.get(col0)?).y0;
if !top.is_finite() || bottom <= top {
return None;
}
let mut t = trial.take().unwrap_or(Trial { key, span: np, lo: top, hi: bottom, tries: 0, done: false });
// This layout fits: its bottom is the best so far.
t.hi = t.hi.min(bottom);
let next = if t.done { t.hi } else { t.next() };
set_limit(limits, key, Some(next));
if t.done {
return None;
}
*trial = Some(t);
Some(key.1)
}
fn set_limit<K: TrialKey>(limits: &mut Rc<Vec<(K, f64)>>, key: K, bottom: Option<f64>) {
let v = Rc::make_mut(limits);
v.retain(|(k, _)| *k != key);
@@ -1962,11 +2034,14 @@ impl Cursor {
fn band_key(&self) -> BandKey {
(self.fi, self.band.para)
}
/// Column `col` of the current frame limited to the band: below the spanning paragraph above
/// it and, while balancing, above the trial bottom.
/// The current column of the frame (`col`) limited to the band: below the spanning paragraph
/// above it and, while balancing, above the trial bottom (Balance Columns limits only the
/// columns after the band's last column break).
fn clip(&self, col: Rect) -> Rect {
let y0 = self.band.above.map_or(col.y0, |a| a.top.max(col.y0).min(col.y1.max(col.y0)));
let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0));
let tail = self.band.tail.filter(|&(_, c)| self.col >= c).map(|(p, _)| (self.fi, p));
let limit = limit_of(&self.limits, self.band_key()).or_else(|| tail.and_then(|k| limit_of(&self.limits, k)));
let y1 = limit.filter(|b| !b.is_nan()).map_or(col.y1, |b| b.min(col.y1).max(y0));
Rect::new(col.x0, y0, col.x1, y1)
}
/// Move on as a paragraph start option (or a break character) asks.
+111
View File
@@ -2788,3 +2788,114 @@ fn empty_paragraphs_get_no_bullet_or_number() {
assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`");
}
}
// ---------- balance columns ----------
/// A story of `paras` body paragraphs in one frame of `columns` columns, Balance Columns `on`.
fn balance_doc(paras: usize, columns: u32, rect: Rect, on: bool) -> (Document, StoryId, ItemId) {
let (mut d, sid, fid) = doc_with(&vec![LOREM; paras].join("\n"), rect, ParaAttrs::default());
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
o.columns = columns;
o.balance_columns = on;
(d, sid, fid)
}
fn column_line_counts(ft: &FrameText) -> Vec<usize> {
(0..ft.columns.len() as u32).map(|c| ft.lines.iter().filter(|l| l.column == c).count()).collect()
}
/// The baseline of each column's last line.
fn column_ends(ft: &FrameText) -> Vec<f64> {
(0..ft.columns.len() as u32).map(|c| ft.lines.iter().filter(|l| l.column == c).map(|l| l.baseline).fold(f64::NEG_INFINITY, f64::max)).collect()
}
#[test]
fn balance_columns_evens_out_the_columns_the_story_ends_in() {
let rect = Rect::new(0.0, 0.0, 400.0, 300.0);
let (d, sid, _) = balance_doc(3, 2, rect, false);
let off = compose_story(&d, sid, &ComposeOptions::default());
let counts = column_line_counts(&off.frames[0]);
assert!(counts[1] > 0 && counts[0] > counts[1] + 2, "fills the first column: {counts:?}");
let (d, sid, _) = balance_doc(3, 2, rect, true);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let ft = &cs.frames[0];
assert_no_overlap(ft);
let on = column_line_counts(ft);
assert_eq!(on.iter().sum::<usize>(), counts.iter().sum::<usize>(), "the same lines");
assert!(on[0] >= on[1] && on[0] - on[1] <= 1, "{on:?}");
let ends = column_ends(ft);
assert!((ends[0] - ends[1]).abs() <= 14.4 + 1e-6, "{ends:?}");
assert_eq!(all_lines(&cs).last().unwrap().range.end, d.story(sid).unwrap().text.len());
// A paragraph kept together stays in one column while the columns still balance.
let (mut d, sid, _) = balance_doc(3, 2, rect, true);
let p = &mut d.story_mut(sid).unwrap().paras[1].para;
p.keep_lines_together = Some(true);
p.keep_all_lines = Some(true);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let kept: Vec<u32> = cs.frames[0].lines.iter().filter(|l| l.para == 1).map(|l| l.column).collect();
assert!(kept.iter().all(|&c| c == kept[0]), "{kept:?}");
let c = column_line_counts(&cs.frames[0]);
assert!(c[1] > 0 && c[0].max(c[1]) < counts[0], "{c:?} vs {counts:?}");
// Three columns: the first two are equal, the last at most two lines shorter.
let (d, sid, _) = balance_doc(3, 3, Rect::new(0.0, 0.0, 540.0, 500.0), true);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let c = column_line_counts(&cs.frames[0]);
let total: usize = c.iter().sum();
assert_eq!(c[0], total.div_ceil(3), "{c:?}");
assert!(c[1] <= c[0] && c[2] <= c[1] && c[0] - c[2] <= 2 && c[2] > 0, "{c:?}");
assert_no_overlap(&cs.frames[0]);
// Vertical stories balance their columns too (bands across the turned frame).
let (mut d, sid, _) = balance_doc(2, 2, Rect::new(0.0, 0.0, 400.0, 400.0), true);
d.story_mut(sid).unwrap().vertical = true;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let c = column_line_counts(&cs.frames[0]);
assert!(c[1] > 0 && c[0].abs_diff(c[1]) <= 1, "{c:?}");
}
#[test]
fn balance_columns_leaves_a_frame_the_text_runs_on_from() {
let rect = Rect::new(0.0, 0.0, 400.0, 150.0);
let layout = |on: bool| {
let (mut d, sid, f1) = balance_doc(4, 2, rect, on);
let lid = d.default_layer();
let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 300.0, 400.0, 700.0), lid, "", ParaFormat::default()).unwrap();
let o = &mut d.item_mut(f2).unwrap().text_frame_mut().unwrap().options;
o.columns = 2;
o.balance_columns = on;
d.thread(f1, f2).unwrap();
compose_story(&d, sid, &ComposeOptions::default())
};
let (off, on) = (layout(false), layout(true));
assert!(!on.is_overset());
let first = |cs: &ComposedStory| cs.frames[0].lines.iter().map(|l| (l.column, l.baseline, l.range.clone())).collect::<Vec<_>>();
assert_eq!(first(&on), first(&off), "the first frame fills normally");
let c = column_line_counts(&on.frames[1]);
assert!(c[1] > 0 && c[0] - c[1] <= 1, "the frame the story ends in is balanced: {c:?}");
}
#[test]
fn balance_columns_starts_after_a_column_break() {
// The first paragraph ends with a column break: the first column holds it alone and the
// other two share the rest.
let text = format!("{LOREM}{}\n{}", designcraft_doc::story::COLUMN_BREAK, [LOREM; 3].join("\n"));
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 540.0, 500.0), ParaAttrs::default());
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
o.columns = 3;
o.balance_columns = true;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let ft = &cs.frames[0];
assert_no_overlap(ft);
assert!(ft.lines.iter().filter(|l| l.column == 0).all(|l| l.para == 0));
assert!(ft.lines.iter().filter(|l| l.para == 0).all(|l| l.column == 0));
let c = column_line_counts(ft);
assert!(c[2] > 0 && c[1] >= c[2] && c[1] - c[2] <= 1, "{c:?}");
}
+40
View File
@@ -1454,3 +1454,43 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() {
check(&d);
check(&import_idml(&export_idml(&d)).unwrap());
}
#[test]
fn vertical_balance_columns_balances_the_imported_frame() {
let text = "Balanced columns end at about the same depth when the story ends in the frame. ".repeat(4);
let designmap = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<idPkg:Spread src="Spreads/Spread_s.xml"/>
<idPkg:Story src="Stories/Story_s1.xml"/>
</Document>"#;
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Spread Self="s">
<Page Self="p" GeometricBounds="0 0 400 400" ItemTransform="1 0 0 1 0 0"/>
<TextFrame Self="f" ParentStory="s1">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 150"/>
<PathPointType Anchor="300 150"/><PathPointType Anchor="300 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<TextFramePreference TextColumnCount="2" TextColumnGutter="12" VerticalBalanceColumns="true"/>
</TextFrame>
</Spread>
</idPkg:Spread>"#;
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
<ParagraphStyleRange><CharacterStyleRange><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange>
</Story></idPkg:Story>"#
);
let mut d = import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", spread), ("Stories/Story_s1.xml", &story)])).unwrap();
let sid = *d.stories.keys().next().unwrap();
let per_column = |d: &Document| {
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(!cs.is_overset());
let ft = &cs.frames[0];
[0, 1].map(|c| ft.lines.iter().filter(|l| l.column == c).count())
};
let on = per_column(&d);
assert!(on[1] > 0 && on[0] - on[1] <= 1, "balanced: {on:?}");
let fid = d.stories[&sid].frames[0];
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.balance_columns = false;
let off = per_column(&d);
assert!(off[0] > off[1] + 1, "unbalanced: {off:?}");
}
+9 -2
View File
@@ -427,7 +427,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
let fm = |v: f64| json!(format_measure(v, units));
let shown = json!({
"columns": o.columns, "gutter": fm(o.gutter),
"columns": o.columns, "gutter": fm(o.gutter), "balanceColumns": o.balance_columns,
"insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]),
"verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null),
"columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color,
@@ -459,6 +459,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
text_field(ui, d, "gutter", 80.0);
ui.end_row();
});
check(ui, d, "balanceColumns", tr("Balance Columns"));
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(tr("Inset spacing")).font(semibold(12.0)));
egui::Grid::new("tfo_inset").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
@@ -551,6 +552,9 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
}
p.insert("inset".into(), json!(inset));
}
if edited(d, "balanceColumns") {
p.insert("balanceColumns".into(), json!(d.b("balanceColumns")));
}
if edited(d, "verticalJustification") {
p.insert("verticalJustification".into(), json!(d.s("verticalJustification")));
}
@@ -4175,12 +4179,15 @@ mod tests {
// One inset side edited: the others keep their values.
open(&mut app, &[("insetLeft", json!("1p0"))]).unwrap();
assert_eq!(opts(&app).inset, [1.0, 12.0, 3.0, 4.0]);
open(&mut app, &[("balanceColumns", json!(true))]).unwrap();
let o = opts(&app);
assert!(o.balance_columns && o.column_rule && o.columns == 2, "{o:?}");
// An unknown swatch is an error and the dialog stays open.
let e = open(&mut app, &[("columnRuleColor", json!("Mauve"))]).unwrap_err();
assert!(e.contains("Mauve") && app.ui.dialog.is_some(), "{e}");
assert_eq!(opts(&app).column_rule_color, designcraft_color::swatch::BLACK);
// Each OK is one undo step.
for _ in 0..3 {
for _ in 0..4 {
app.run("edit.undo", json!({})).unwrap();
}
assert_eq!(opts(&app), before);
+1
View File
@@ -1296,6 +1296,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Close Path", ["Close Path", "Close Path", "Close Path", "Close Path", "闭合路径"]),
("Color Group Options", ["Color Group Options", "Color Group Options", "Color Group Options", "Color Group Options", "颜色组选项"]),
("Column Width", ["Column Width", "Column Width", "Column Width", "Column Width", "列宽"]),
("Balance Columns", ["Spalten ausgleichen", "Équilibrer les colonnes", "Equilibrar columnas", "段の高さを揃える", "平衡栏高"]),
("Column Rules", ["Spaltenlinien", "Filets de colonne", "Filetes de columna", "段間罫線", "栏间线"]),
("Insert Column Rule", ["Spaltenlinie einfügen", "Insérer un filet de colonne", "Insertar filete de columna", "段間罫線を挿入", "插入栏间线"]),
("Tint %", ["Farbton %", "Teinte %", "Matiz %", "濃淡 %", "色调 %"]),
+1
View File
@@ -337,6 +337,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- 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.
- 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.
+1
View File
@@ -49,6 +49,7 @@ Fidelity: **unmeasured** unless stated.
| Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | |
| Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) |
| Column rules | ✓ | ✓ (screen, PDF, IDML) | ~ | no stroke types or overprint (#274) |
| Balance columns | ✓ | ✓ (IDML `VerticalBalanceColumns`) | ✓ | evens out the columns of the frame the story ends in, after its last column break (#609) |
| Keep options and breaks | keep with next, lines together, start in next column/frame/page/odd/even | ✓ | ✓ | break characters end paragraphs (#285); the line before a break is a last line (#283) |
| Drop caps, nested, line and GREP styles | ✓ | ✓ | ✓ | |
| Bullets and numbering | lists, levels, formats, restart, across stories | ✓ | ~ | numbering expressions from IDML (#298); Convert Bullets/Numbering to Text missing |