From 1efbf0f894e07514aa4759b6e873dcd9227b443d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 00:26:13 +0000 Subject: [PATCH] Text frames: column rules draw between columns on screen and in PDF A frame with Column Rule on gets a bar centred in each gutter, inside the insets, broken around paragraphs that span columns; right-to-left and vertical frames included. object.textFrameOptions takes columnRuleWeight and columnRuleColor (unknown swatches are an error) and an inset array whose null sides stay; document.inspect reports the rule. --- crates/compose/src/lib.rs | 64 +++++++++++++++++++++++ crates/compose/src/tests.rs | 89 ++++++++++++++++++++++++++++++++ crates/engine/src/cmd/inspect.rs | 5 ++ crates/engine/src/cmd/object.rs | 72 ++++++++++++++++++++++++-- crates/pdf/src/tests.rs | 21 ++++++++ 5 files changed, 248 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8f..a0815d2af 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1036,6 +1036,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C last_end = ft.range.end; } } + // Column rules, once vertical justification has put the lines in place. + for (ft, f) in out.frames.iter_mut().zip(frames) { + if f.opts.column_rule { + let rects = column_rule_rects(&ft.columns, &ft.lines, f.opts.column_rule_weight); + ft.decos.extend(rects.into_iter().map(|rect| Deco { rect, color: f.opts.column_rule_color.clone(), tint: 1.0 })); + } + } out.styles = styles_tab; mark_keep_violations(doc, story, &mut out); out @@ -1132,6 +1139,63 @@ fn para_box_decos(out: &mut ComposedStory, pi: usize, pp: &ParaProps) { } } +/// Thickest column rule drawn (points). +const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0; + +/// Text Frame Options › Column Rules: one bar per gutter, `weight` wide and centred between the +/// facing edges of the two columns, from the columns' top to their bottom (the text area, inside +/// the 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. +/// +/// 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. +pub fn column_rule_rects(columns: &[Rect], lines: &[Line], weight: f64) -> Vec { + if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 { + return Vec::new(); + } + let half = weight.min(MAX_COLUMN_RULE_WEIGHT) / 2.0; + let mut cols: Vec = + columns.iter().copied().filter(|c| c.x0.is_finite() && c.x1.is_finite() && c.y0.is_finite() && c.y1.is_finite()).collect(); + cols.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + let mut out = Vec::new(); + 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), a.y1.min(b.y1)); + if bottom <= top { + continue; + } + // Vertical bands of the paragraphs whose lines cross this gutter. + let mut spans: Vec<(usize, f64, f64)> = Vec::new(); + for l in lines.iter().filter(|l| l.x0 < cx && l.x1 > cx) { + let (t, z) = (l.baseline - l.ascent, l.baseline + l.descent); + match spans.iter_mut().find(|s| s.0 == l.para) { + Some(s) => { + s.1 = s.1.min(t); + s.2 = s.2.max(z); + } + None => spans.push((l.para, t, z)), + } + } + spans.sort_by(|p, q| p.1.total_cmp(&q.1)); + let mut y = top; + for (_, t, z) in spans { + if t > y { + out.push(Rect::new(cx - half, y, cx + half, t.min(bottom))); + } + y = y.max(z); + if y >= bottom { + break; + } + } + if y < bottom { + out.push(Rect::new(cx - half, y, cx + half, bottom)); + } + } + out +} + /// Breaker parameters from the paragraph's settings. fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { Spacing { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 549e7bccf..e73ec69a6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1877,3 +1877,92 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, StoryId)) -> (Document, ItemId, Vec) { + let (mut d, sid, fid) = doc_with(text, rect, ParaAttrs::default()); + { + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.columns = columns; + o.gutter = 12.0; + o.inset = [10.0, 6.0, 20.0, 6.0]; + o.column_rule = true; + o.column_rule_weight = 2.0; + o.column_rule_color = "[Black]".into(); + } + edit(&mut d, sid); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let rules = cs.frames[0].decos.iter().filter(|dc| dc.color == "[Black]").map(|dc| dc.rect).collect(); + (d, fid, rules) +} + +#[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, |_, _| {}); + 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:?}"); + // 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:?}"); + let w = (288.0 - 24.0) / 3.0; + for (i, r) in rules.iter().enumerate() { + let edge = 6.0 + w * (i + 1) as f64 + 12.0 * i as f64; + assert!((r.center().x - (edge + 6.0)).abs() < 1e-9, "{i}: {r:?}"); + } + // Off, or a single column: no rule. + let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 1, |_, _| {}); + assert!(rules.is_empty()); + 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]; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.column_rule = false; + }); + assert!(rules.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)]; + let rules = column_rule_rects(&cols, &[], 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, &[], f64::NAN).is_empty()); + assert!(column_rule_rects(&cols, &[], -1.0).is_empty()); + assert!((column_rule_rects(&cols, &[], 1e12)[0].width() - 1000.0).abs() < 1e-9); +} + +#[test] +fn column_rules_in_right_to_left_and_vertical_frames() { + let (_, _, rules) = ruled("نص", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft; + }); + assert_eq!(rules.len(), 1); + assert!((rules[0].center().x - 150.0).abs() < 1e-9, "{rules:?}"); + // 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 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:?}"); + assert!((r.center().y - 195.0).abs() < 1e-6 && (r.height() - 2.0).abs() < 1e-6, "{r:?}"); +} + +#[test] +fn column_rules_break_around_paragraphs_that_span_columns() { + let text = format!("A heading that spans both columns\n{LOREM} {LOREM}"); + let (d, fid, rules) = ruled(&text, Rect::new(0.0, 0.0, 300.0, 400.0), 2, |d, sid| { + d.story_mut(sid).unwrap().paras[0].para.span_columns = Some(designcraft_doc::SpanColumns::Span(0)); + }); + let sid = d.item(fid).unwrap().text_frame().unwrap().story; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let span: Vec<&Line> = cs.frames[0].lines.iter().filter(|l| l.para == 0).collect(); + assert!(!span.is_empty() && span.iter().all(|l| l.x0 < 150.0 && l.x1 > 150.0), "the heading spans"); + let (top, bottom) = (span[0].baseline - span[0].ascent, span[span.len() - 1].baseline + span[span.len() - 1].descent); + assert!(!rules.is_empty()); + 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:?}"); +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 6ec9c7121..200ce1d63 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -41,6 +41,11 @@ fn item_json(it: &Item) -> Value { Content::Text(t) => { v["story"] = json!(t.story.0); v["columns"] = json!(t.options.columns); + v["columnRule"] = json!(t.options.column_rule); + if t.options.column_rule { + v["columnRuleWeight"] = json!(t.options.column_rule_weight); + v["columnRuleColor"] = json!(t.options.column_rule_color); + } } Content::Graphic(g) => v["asset"] = json!(g.asset.0), Content::Group { items } => v["children"] = Value::Array(items.iter().map(|c| item_json(c)).collect()), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 40802d548..ccc6acaf3 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -479,7 +479,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, 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?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -1410,6 +1410,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result { } fn text_frame_options(s: &mut Session, p: &Value) -> Result { + const ID: &str = "object.textFrameOptions"; let st = s.doc()?; let mut ids = targets(s, p)?; if ids.is_empty() @@ -1417,6 +1418,18 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { { ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default(); } + let rule_weight = match p.get("columnRuleWeight") { + None | Some(Value::Null) => None, + Some(v) => match v.as_f64() { + Some(w) if w.is_finite() && (0.0..=1000.0).contains(&w) => Some(w), + _ => return Err(bad(ID, format!("columnRuleWeight must be a number of points from 0 to 1000, not {v}"))), + }, + }; + 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()), + Some(v) => return Err(bad(ID, format!("no swatch {v}"))), + }; let p = p.clone(); s.edit(|d, _| { for id in &ids { @@ -1431,8 +1444,12 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { match p.get("inset") { Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4], Some(v @ Value::Array(_)) => { - if let Ok(a) = serde_json::from_value(v.clone()) { - o.inset = a; + if let Ok(a) = serde_json::from_value::<[Option; 4]>(v.clone()) { + for (side, v) in o.inset.iter_mut().zip(a) { + if let Some(v) = v { + *side = v; + } + } } } _ => {} @@ -1456,6 +1473,12 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("columnRule").and_then(Value::as_bool) { o.column_rule = v; } + if let Some(w) = rule_weight { + o.column_rule_weight = w; + } + if let Some(c) = &rule_color { + o.column_rule_color = c.clone(); + } } // Story direction belongs to the story: every frame of the thread turns. if let Some(v) = p.get("vertical").and_then(Value::as_bool) { @@ -2421,3 +2444,46 @@ mod named_target_tests { assert_eq!(s.execute("conveyor.collect", &json!({"id": r["id"]})).unwrap()["count"], 1); } } + +#[cfg(test)] +mod column_rule_tests { + use super::*; + + #[test] + fn column_rules_set_validate_undo_and_inspect() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let opts = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": 3, "inset": [1, 2, 3, 4]})).unwrap(); + let before = opts(&s); + // An unknown swatch or a bad weight is an error and changes nothing. + let e = s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRule": true, "columnRuleColor": "Mauve"})).unwrap_err(); + assert!(e.to_string().contains("Mauve"), "{e}"); + assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleWeight": -1})).is_err()); + assert_eq!(opts(&s), before); + s.execute( + "object.textFrameOptions", + &json!({"ids": [id.0], "columnRule": true, "columnRuleWeight": 0.5, "columnRuleColor": "C=100 M=0 Y=0 K=0"}), + ) + .unwrap(); + let o = opts(&s); + assert!(o.column_rule && o.column_rule_weight == 0.5 && o.column_rule_color == "C=100 M=0 Y=0 K=0"); + assert_eq!(o.inset, [1.0, 2.0, 3.0, 4.0], "untouched options stay"); + let item = s.execute("document.inspect", &json!({})).unwrap()["spreads"][0]["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["id"] == json!(id.0)) + .cloned() + .unwrap(); + assert_eq!( + (&item["columnRule"], &item["columnRuleWeight"], &item["columnRuleColor"]), + (&json!(true), &json!(0.5), &json!("C=100 M=0 Y=0 K=0")) + ); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(opts(&s), before); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1771c4e4f..27fec660a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -337,3 +337,24 @@ fn missing_glyphs_print_as_boxes() { assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::>(), ["a", "b"], "{glyphs:?}"); assert_eq!(boxes(&paths), 1, "{paths:?}"); } + +#[test] +fn column_rules_are_drawn() { + let rules = |on: bool| { + let mut d = doc_with_text("Column text"); + let fid = d.stories.values().next().unwrap().frames[0]; + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.columns = 3; + o.gutter = 12.0; + o.inset = [0.0; 4]; + o.column_rule = on; + o.column_rule_weight = 2.0; + 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::>() + }; + // 540 pt wide in three columns with 12 pt gutters: columns of 172 pt from x 36. + let xs = rules(true); + assert_eq!(xs.len(), 2, "{xs:?}"); + assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}"); + assert!(rules(false).is_empty()); +}