From 5f77ea123b3622318fdefeb9073007e2b1ce3f46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:39:54 -0700 Subject: [PATCH] Text frames: column rules run from the top line's ascent to the bottom line's descent A column rule ran from the top of the columns to their bottom, so a frame that the text only half fills drew its rules down to the empty bottom of the frame. InDesign draws them from the top line's ascent to the bottom line's descent. column_rule_rects now takes that extent from the frame's lines, and the top and bottom insets shorten the rule from there. A frame without lines draws no rules. Existing documents draw their column rules at the new length, on screen, in PDF and in PowerPoint export. --- ROADMAP.md | 3 +- crates/compose/src/lib.rs | 23 ++++++++---- crates/compose/src/tests.rs | 73 ++++++++++++++++++++++++++++++------- crates/pdf/src/tests.rs | 9 ++++- docs/target-app-parity.md | 2 +- 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 97456e8ea..2aacaf52f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,7 +95,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|art|trim|bleed|media|contentVisible|contentAll}`, Place PDF dialog): the graphic spans that page box, the document stores the choice, and IDML keeps it as `PDFCrop`. - 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. +- 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 from the top line's ascent to the bottom line's descent, 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. - 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. @@ -361,6 +361,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 543f2430d..ef67963c5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2244,19 +2244,28 @@ fn mark_keep_violations(doc: &Document, story: &Story, out: &mut ComposedStory) const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0; /// Text Frame Options › Column Rules: one bar per gutter, `column_rule_weight` wide, centred -/// between the facing edges of the two columns and moved by `column_rule_offset`, from the -/// columns' top to their bottom (the text area, inside the insets) shortened by the rule's top -/// and bottom insets. Works in the composed space, so right-to-left and vertical frames (whose -/// columns are laid out in the turned box) get their rules between the columns too. +/// between the facing edges of the two columns and moved by `column_rule_offset`, from the top of +/// the frame's top line (its ascent) to the bottom of its bottom line (its descent), as InDesign +/// draws them, shortened by the rule's top and bottom insets (a negative inset extends it). Works +/// in the composed space, so right-to-left and vertical frames (whose columns are laid out in the +/// turned box) get their rules between the columns too. /// /// A paragraph that spans columns interrupts the rule: the bar stops at the top of its first line -/// and resumes below its last line. Nothing is drawn for a single column or a weight that is not a -/// positive finite number. +/// and resumes below its last line. Nothing is drawn for a frame without lines, a single column or +/// a weight that is not a positive finite number. pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptions) -> Vec { let weight = opts.column_rule_weight; if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 { return Vec::new(); } + let (text_top, text_bottom) = lines + .iter() + .map(|l| (l.baseline - l.ascent, l.baseline + l.descent)) + .filter(|(t, z)| t.is_finite() && z.is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(t, z), (lt, lz)| (t.min(lt), z.max(lz))); + if text_bottom < text_top { + return Vec::new(); + } let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; let (offset, top_inset, bottom_inset) = (finite(opts.column_rule_offset), finite(opts.column_rule_top_inset), finite(opts.column_rule_bottom_inset)); @@ -2268,7 +2277,7 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio for pair in cols.windows(2) { let [a, b] = [pair[0], pair[1]]; let cx = (a.x1 + b.x0) / 2.0; - let (top, bottom) = (a.y0.max(b.y0) + top_inset, a.y1.min(b.y1) - bottom_inset); + let (top, bottom) = (text_top + top_inset, text_bottom - bottom_inset); if bottom <= top { continue; } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 300253aa9..3083261fb 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -5703,14 +5703,27 @@ fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, (d, fid, rules) } +/// Returns the top of the first frame's top line (its ascent) and the bottom of its bottom line +/// (its descent). +fn text_extent(d: &Document, fid: ItemId) -> (f64, f64) { + let sid = d.item(fid).unwrap().text_frame().unwrap().story; + let cs = compose_story(d, sid, &ComposeOptions::default()); + let lines = &cs.frames[0].lines; + assert!(!lines.is_empty()); + let top = lines.iter().map(|l| l.baseline - l.ascent).fold(f64::INFINITY, f64::min); + let bottom = lines.iter().map(|l| l.baseline + l.descent).fold(f64::NEG_INFINITY, f64::max); + (top, bottom) +} + #[test] fn column_rules_sit_in_the_gutters_inside_the_insets() { // Two columns in 0..300 with 6 pt side insets: the gutter is centred on 150. - let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {}); + let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {}); assert_eq!(rules.len(), 1, "{rules:?}"); let r = rules[0]; assert!((r.center().x - 150.0).abs() < 1e-9 && (r.width() - 2.0).abs() < 1e-9, "{r:?}"); - assert!((r.y0 - 10.0).abs() < 1e-9 && (r.y1 - 180.0).abs() < 1e-9, "{r:?}"); + let (top, bottom) = text_extent(&d, fid); + assert!((r.y0 - top).abs() < 1e-9 && (r.y1 - bottom).abs() < 1e-9, "{r:?} for the text {top}..{bottom}"); // Three columns: one rule per gutter, each centred between its two columns. let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 3, |_, _| {}); assert_eq!(rules.len(), 2, "{rules:?}"); @@ -5729,16 +5742,36 @@ fn column_rules_sit_in_the_gutters_inside_the_insets() { assert!(rules.is_empty()); } +/// Column rules run from the top line's ascent to the bottom line's descent: text that fills the +/// top of a tall frame gets rules down to its bottom line, not to the bottom of the columns. +#[test] +fn column_rules_run_from_the_top_lines_ascent_to_the_bottom_lines_descent() { + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 600.0), 2, |_, _| {}); + let (top, bottom) = text_extent(&d, fid); + assert!(bottom < 300.0, "the text fills the top of the 10..580 columns: {top}..{bottom}"); + assert_eq!(rules.len(), 1, "{rules:?}"); + assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}"); + // Without lines there is no rule. + let cols = [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 200.0, 50.0)]; + assert!(column_rule_rects(&cols, &[], &TextFrameOptions { column_rule_weight: 1.0, ..Default::default() }).is_empty()); +} + #[test] fn column_rules_centre_on_unequal_gutters_and_ignore_bad_weights() { let cols = [Rect::new(200.0, 0.0, 300.0, 50.0), Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 180.0, 50.0)]; + // One line in the first column, from 0 to 50. + let (d, sid, _) = doc_with("Text", Rect::new(0.0, 0.0, 100.0, 50.0), ParaAttrs::default()); + let mut line = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + (line.x0, line.x1, line.ascent, line.descent, line.baseline) = (0.0, 100.0, 40.0, 10.0, 40.0); + let lines = [line]; let weight = |w: f64| TextFrameOptions { column_rule_weight: w, ..Default::default() }; - let rules = column_rule_rects(&cols, &[], &weight(1.0)); + let rules = column_rule_rects(&cols, &lines, &weight(1.0)); let centres: Vec = rules.iter().map(|r| r.center().x).collect(); assert_eq!(centres, [105.0, 190.0]); - assert!(column_rule_rects(&cols, &[], &weight(f64::NAN)).is_empty()); - assert!(column_rule_rects(&cols, &[], &weight(-1.0)).is_empty()); - assert!((column_rule_rects(&cols, &[], &weight(1e12))[0].width() - 1000.0).abs() < 1e-9); + assert!(rules.iter().all(|r| r.y0 == 0.0 && r.y1 == 50.0), "{rules:?}"); + assert!(column_rule_rects(&cols, &lines, &weight(f64::NAN)).is_empty()); + assert!(column_rule_rects(&cols, &lines, &weight(-1.0)).is_empty()); + assert!((column_rule_rects(&cols, &lines, &weight(1e12))[0].width() - 1000.0).abs() < 1e-9); } #[test] @@ -5751,9 +5784,12 @@ fn column_rules_in_right_to_left_and_vertical_frames() { // Vertical type: the columns stack top to bottom, so the rule runs across the frame. let (d, fid, rules) = ruled("縦書き", Rect::new(100.0, 100.0, 400.0, 300.0), 2, |d, sid| d.story_mut(sid).unwrap().vertical = true); assert_eq!(rules.len(), 1); + let (top, bottom) = text_extent(&d, fid); + assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}"); let r = d.text_xf(d.item(fid).unwrap()).transform_rect_bbox(rules[0]); - // Text area: x 106..394, y 110..280; the gutter is centred on y 195. - assert!((r.x0 - 106.0).abs() < 1e-6 && (r.x1 - 394.0).abs() < 1e-6, "{r:?}"); + // Text area: x 106..394, y 110..280; the gutter is centred on y 195, and the one line of + // text runs from the right edge, so the rule is as long as that line is deep. + assert!(r.x0 > 106.0 && (r.x1 - 394.0).abs() < 2.0 && (r.width() - (bottom - top)).abs() < 1e-6, "{r:?}"); assert!((r.center().y - 195.0).abs() < 1e-6 && (r.height() - 2.0).abs() < 1e-6, "{r:?}"); } @@ -5772,13 +5808,15 @@ fn column_rules_break_around_paragraphs_that_span_columns() { for r in &rules { assert!(r.y1 <= top + 1e-9 || r.y0 >= bottom - 1e-9, "rule {r:?} cuts the span {top}..{bottom}"); } - // The rule resumes below the span and runs to the bottom of the columns. - assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - 380.0).abs() < 1e-9), "{rules:?}"); + // The rule resumes below the span and runs to the bottom line's descent. + let (_, text_bottom) = text_extent(&d, fid); + assert!(text_bottom < 380.0 - 1.0, "the text ends above the columns' bottom: {text_bottom}"); + assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - text_bottom).abs() < 1e-9), "{rules:?}"); } #[test] fn column_rules_take_their_offset_insets_and_tint() { - let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { let fid = d.story(sid).unwrap().frames[0]; let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; o.column_rule_offset = 3.0; @@ -5786,13 +5824,22 @@ fn column_rules_take_their_offset_insets_and_tint() { o.column_rule_bottom_inset = 25.0; o.column_rule_tint = 0.4; }); - // Gutter centre 150 moved 3 pt; columns 10..180 shortened to 25..155. + // Gutter centre 150 moved 3 pt; the text's extent shortened by 15 at the top and 25 at the + // bottom. + let (top, bottom) = text_extent(&d, fid); assert_eq!(rules.len(), 1, "{rules:?}"); let r = rules[0]; - assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - 25.0).abs() < 1e-9 && (r.y1 - 155.0).abs() < 1e-9, "{r:?}"); + assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - (top + 15.0)).abs() < 1e-9 && (r.y1 - (bottom - 25.0)).abs() < 1e-9, "{r:?}"); let sid = d.item(fid).unwrap().text_frame().unwrap().story; let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(cs.frames[0].decos.iter().any(|dc| dc.rect == r && (dc.tint - 0.4).abs() < 1e-6)); + // A negative inset extends the rule past the text. + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + let fid = d.story(sid).unwrap().frames[0]; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.column_rule_top_inset = -4.0; + }); + let (top, bottom) = text_extent(&d, fid); + assert!(rules.len() == 1 && (rules[0].y0 - (top - 4.0)).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?}"); // Insets that meet leave no rule. let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { let fid = d.story(sid).unwrap().frames[0]; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 663ef9169..6e880af44 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -604,8 +604,15 @@ fn column_rules_are_drawn() { o.inset = [0.0; 4]; o.column_rule = on; o.column_rule_weight = 2.0; + // The rules run from the top line's ascent to the bottom line's descent, not the 264 pt + // of the columns. + let sid = *d.stories.keys().next().unwrap(); + let cs = designcraft_compose::compose_story(&d, sid, &designcraft_compose::ComposeOptions::default()); + let line = &cs.frames[0].lines[0]; + let depth = line.ascent + line.descent; + assert!(cs.frames[0].lines.len() == 1 && depth < 264.0 - 1.0, "{depth}"); let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); - paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - 264.0).abs() < 0.01).map(|r| r.center().x).collect::>() + paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - depth).abs() < 0.01).map(|r| r.center().x).collect::>() }; // 540 pt wide in three columns with 12 pt gutters: columns of 172 pt from x 36. let xs = rules(true); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 28551d1a8..b0f2ffab7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -348,7 +348,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. - 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. +- 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 from the top line's ascent to the bottom line's descent, 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 (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. - 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).