diff --git a/ROADMAP.md b/ROADMAP.md index bf8f5d10b..c02176bab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,7 +115,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. @@ -381,6 +381,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. +- **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-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 0146fe60b..49a48b8b8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2255,19 +2255,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)); @@ -2279,7 +2288,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 a5c19edc5..2accc79fa 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -623,8 +623,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 ce1be8a92..3443ca8e6 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).