From 60ad82497bf615e7d9c7e8d2c3a5adc92aa3fde3 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:49:00 -0400 Subject: [PATCH] Text frames: column rules on screen, in PDF and in IDML; Column Rules in Text Frame Options (#274) * 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. * Text Frame Options: Column Rules fields; OK changes only edited fields The dialog shows the selected frame's options, with an inset field per side, and sends only what the user changed, so unequal insets survive. The rule weight is read in points. * Column rules: tint, horizontal offset and top/bottom insets; IDML import and export Text Frame Options gains Tint %, Top Inset, Bottom Inset and Horizontal Offset for the rule, and object.textFrameOptions and document.inspect take and report them. IDML reads the ColumnRule attributes of TextFramePreference on frames and object styles and writes them when a rule is set. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 71 +++++++++ crates/compose/src/tests.rs | 117 ++++++++++++++ crates/doc/src/item.rs | 11 ++ crates/engine/src/cmd/inspect.rs | 9 ++ crates/engine/src/cmd/object.rs | 97 +++++++++++- crates/idml/src/export.rs | 27 +++- crates/idml/src/import.rs | 36 ++++- crates/idml/src/lib.rs | 7 +- crates/idml/src/tests.rs | 46 ++++++ crates/pdf/src/tests.rs | 21 +++ crates/ui-egui/src/dialogs.rs | 263 ++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n.rs | 6 + crates/ui-egui/src/i18n/ar.rs | 6 + crates/ui-egui/src/i18n/pt_br.rs | 6 + 15 files changed, 686 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91893b6..31fd09c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,6 +80,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|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. - 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. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f6ee7ee..6e745d8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1154,6 +1154,14 @@ 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); + let tint = if f.opts.column_rule_tint.is_finite() { f.opts.column_rule_tint.clamp(0.0, 1.0) } else { 1.0 }; + ft.decos.extend(rects.into_iter().map(|rect| Deco { rect, color: f.opts.column_rule_color.clone(), tint })); + } + } out.styles = styles_tab; mark_keep_violations(doc, story, &mut out); out @@ -1258,6 +1266,69 @@ 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, `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. +/// +/// 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], opts: &TextFrameOptions) -> Vec { + let weight = opts.column_rule_weight; + if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 { + 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)); + 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) + top_inset, a.y1.min(b.y1) - bottom_inset); + 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 x = cx + offset; + let mut y = top; + for (_, t, z) in spans { + if t > y { + out.push(Rect::new(x - half, y, x + half, t.min(bottom))); + } + y = y.max(z); + if y >= bottom { + break; + } + } + if y < bottom { + out.push(Rect::new(x - half, y, x + 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 70b0198..1f11dcd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1937,6 +1937,123 @@ fn vertical_lines_fit_the_em_box() { } } +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 weight = |w: f64| TextFrameOptions { column_rule_weight: w, ..Default::default() }; + let rules = column_rule_rects(&cols, &[], &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); +} + +#[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:?}"); +} + +#[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 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; + o.column_rule_top_inset = 15.0; + 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. + 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:?}"); + 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)); + // 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]; + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.column_rule_top_inset = 100.0; + o.column_rule_bottom_inset = 100.0; + }); + assert!(rules.is_empty(), "{rules:?}"); +} + // ---------- span columns ---------- /// A story of `before` body paragraphs, a heading spanning all columns, and `after` body diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5492196..5cfe05f 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -326,6 +326,13 @@ pub struct TextFrameOptions { pub column_rule: bool, pub column_rule_weight: f64, pub column_rule_color: String, + /// 0..1. + pub column_rule_tint: f32, + /// Horizontal offset of each rule from the middle of its gutter. + pub column_rule_offset: f64, + /// The rule starts this far below the column top and ends this far above its bottom. + pub column_rule_top_inset: f64, + pub column_rule_bottom_inset: f64, /// Use a custom baseline grid for this frame. #[serde(skip_serializing_if = "Option::is_none")] pub baseline_grid: Option<(f64, f64)>, @@ -375,6 +382,10 @@ impl Default for TextFrameOptions { column_rule: false, column_rule_weight: 1.0, column_rule_color: designcraft_color::swatch::BLACK.into(), + column_rule_tint: 1.0, + column_rule_offset: 0.0, + column_rule_top_inset: 0.0, + column_rule_bottom_inset: 0.0, baseline_grid: None, path: None, } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 6ec9c71..f3c2733 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -41,6 +41,15 @@ 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); + v["columnRuleTint"] = json!(t.options.column_rule_tint); + v["columnRuleOffset"] = json!(t.options.column_rule_offset); + v["columnRuleTopInset"] = json!(t.options.column_rule_top_inset); + v["columnRuleBottomInset"] = json!(t.options.column_rule_bottom_inset); + } } 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 7e219f2..c03d9ff 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -489,7 +489,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), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -1421,6 +1421,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() @@ -1428,6 +1429,23 @@ 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 ranged = |key: &str, lo: f64, hi: f64, what: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)), + _ => Err(bad(ID, format!("{key} must be {what} from {lo} to {hi}, not {v}"))), + }, + }; + let rule_weight = ranged("columnRuleWeight", 0.0, 1000.0, "a number of points")?; + let rule_tint = ranged("columnRuleTint", 0.0, 1.0, "a tint")?; + let rule_offset = ranged("columnRuleOffset", -1440.0, 1440.0, "a number of points")?; + let rule_top = ranged("columnRuleTopInset", -1440.0, 1440.0, "a number of points")?; + let rule_bottom = ranged("columnRuleBottomInset", -1440.0, 1440.0, "a number of points")?; + 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 { @@ -1442,8 +1460,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; + } + } } } _ => {} @@ -1467,6 +1489,24 @@ 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(); + } + if let Some(t) = rule_tint { + o.column_rule_tint = t as f32; + } + if let Some(v) = rule_offset { + o.column_rule_offset = v; + } + if let Some(v) = rule_top { + o.column_rule_top_inset = v; + } + if let Some(v) = rule_bottom { + o.column_rule_bottom_inset = v; + } } // Story direction belongs to the story: every frame of the thread turns. if let Some(v) = p.get("vertical").and_then(Value::as_bool) { @@ -2433,6 +2473,57 @@ mod named_target_tests { } } +#[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!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleTint": 2})).is_err()); + assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleTopInset": "x"})).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", "columnRuleTint": 0.5, + "columnRuleOffset": -2, "columnRuleTopInset": 4, "columnRuleBottomInset": 6}), + ) + .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"); + assert_eq!((o.column_rule_tint, o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset), (0.5, -2.0, 4.0, 6.0)); + 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")) + ); + assert_eq!( + (&item["columnRuleTint"], &item["columnRuleOffset"], &item["columnRuleTopInset"], &item["columnRuleBottomInset"]), + (&json!(0.5), &json!(-2.0), &json!(4.0), &json!(6.0)) + ); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(opts(&s), before); + } +} + #[cfg(test)] mod blend_mode_tests { use designcraft_color::BlendMode; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 09fa22f..c63815b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -992,7 +992,7 @@ impl<'a> Ex<'a> { } let mut el = with_props(el, props); if let Some(tf) = &os.text_frame { - el.push(text_frame_pref(tf)); + el.push(text_frame_pref(tf, &self.sw(&tf.column_rule_color))); } og.insert(&os.name, el); } @@ -1627,7 +1627,7 @@ impl<'a> Ex<'a> { el.push(t); } if let Content::Text(t) = &it.content { - el.push(text_frame_pref(&t.options)); + el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color))); if let Some((start, inc)) = t.options.baseline_grid { el.push( El::new("BaselineFrameGridOption") @@ -2287,7 +2287,8 @@ fn margin_el(pg: &designcraft_doc::Page) -> El { .attr("ColumnsPositions", pos.join(" ")) } -fn text_frame_pref(o: &TextFrameOptions) -> El { +/// `rule_color` is the IDML reference of the column rule's swatch. +fn text_frame_pref(o: &TextFrameOptions, rule_color: &str) -> El { let mut inset = El::new("InsetSpacing").attr("type", "list"); for v in o.inset { inset.push(p("ListItem", "unit", num(v))); @@ -2306,6 +2307,26 @@ fn text_frame_pref(o: &TextFrameOptions) -> El { .attr("AutoSizingType", names::auto_size_out(o.auto_size)) .attr("AutoSizingReferencePoint", names::REF_POINTS[(o.auto_size_ref as usize).min(8)]); let el = if o.column_width > 0.0 { el.attr("TextColumnFixedWidth", num(o.column_width)) } else { el }; + let d = TextFrameOptions::default(); + let rule_set = o.column_rule + || o.column_rule_weight != d.column_rule_weight + || o.column_rule_color != d.column_rule_color + || o.column_rule_tint != d.column_rule_tint + || o.column_rule_offset != d.column_rule_offset + || o.column_rule_top_inset != d.column_rule_top_inset + || o.column_rule_bottom_inset != d.column_rule_bottom_inset; + let el = if rule_set { + el.attr("ColumnRuleOverride", bool_s(o.column_rule)) + .attr("ColumnRuleStrokeWidth", num(o.column_rule_weight)) + .attr("ColumnRuleStrokeColor", rule_color) + .attr("ColumnRuleStrokeTint", num(f64::from(o.column_rule_tint) * 100.0)) + .attr("ColumnRuleOffset", num(o.column_rule_offset)) + .attr("ColumnRuleTopInset", num(o.column_rule_top_inset)) + .attr("ColumnRuleBottomInset", num(o.column_rule_bottom_inset)) + .attr("ColumnRuleInsetChainOverride", bool_s(o.column_rule_top_inset == o.column_rule_bottom_inset)) + } else { + el + }; with_props(el, vec![inset]) } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index a958e65..a8f7e7a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -688,6 +688,15 @@ impl<'r> Importer<'r> { } /// A swatch reference (Self id) → our swatch name; unnamed colours become value-named swatches. + /// A `TextFramePreference`, with its column rule colour resolved to a swatch. + fn frame_options(&mut self, e: &El) -> TextFrameOptions { + let mut o = text_frame_options(e); + if let Some(r) = e.get("ColumnRuleStrokeColor") { + o.column_rule_color = self.swatch_ref(r); + } + o + } + fn swatch_ref(&mut self, r: &str) -> String { if let Some(n) = self.swatch_names.get(r) { return n.clone(); @@ -898,8 +907,11 @@ impl<'r> Importer<'r> { let paragraph_style = enabled("EnableParagraphStyle", "AppliedParagraphStyle") .then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r))) .flatten(); - let text_frame = - if e.get("EnableTextFrameGeneralOptions") == Some("true") { e.find("TextFramePreference").map(text_frame_options) } else { None }; + let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") { + e.find("TextFramePreference").map(|t| self.frame_options(t)) + } else { + None + }; let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame }; match self.styles.object.iter_mut().find(|o| o.name == name) { Some(slot) => *slot = s, @@ -2046,7 +2058,7 @@ impl<'r> Importer<'r> { sid } }; - let mut options = e.find("TextFramePreference").map(text_frame_options).unwrap_or_default(); + let mut options = e.find("TextFramePreference").map(|t| self.frame_options(t)).unwrap_or_default(); if let Some(g) = e.find("BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { @@ -2380,6 +2392,24 @@ fn text_frame_options(e: &El) -> TextFrameOptions { if let Some(v) = e.get("AutoSizingReferencePoint") { o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8; } + // Column rules (the colour is a swatch reference: see `Importer::frame_options`). + o.column_rule = e.get("ColumnRuleOverride") == Some("true"); + let finite = |k: &str| e.num(k).filter(|v| v.is_finite()); + if let Some(v) = finite("ColumnRuleStrokeWidth") { + o.column_rule_weight = v.clamp(0.0, 1000.0); + } + if let Some(t) = tint(finite("ColumnRuleStrokeTint")) { + o.column_rule_tint = t.clamp(0.0, 1.0); + } + for (k, v) in [ + ("ColumnRuleOffset", &mut o.column_rule_offset), + ("ColumnRuleTopInset", &mut o.column_rule_top_inset), + ("ColumnRuleBottomInset", &mut o.column_rule_bottom_inset), + ] { + if let Some(x) = finite(k) { + *v = x.clamp(-1440.0, 1440.0); + } + } o } diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index c82f0c7..71d6882 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -20,8 +20,8 @@ //! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections, //! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`, //! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text -//! wrap, transparency (opacity, blend mode, drop shadow, feather), images (embedded `Contents` or -//! linked), stories with paragraph/character style ranges and local overrides, special +//! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow, +//! feather), images (embedded `Contents` or linked), stories with paragraph/character style ranges and local overrides, special //! characters and breaks, and text threading. //! //! ## TODO (ignored on import / not written on export) @@ -30,7 +30,8 @@ //! - Guides, page-item overrides of parent items (only the page's `OverrideList` is read), //! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid layout. //! - Nested/GREP/line styles, bullets & numbering details (bullet glyph, number format), OpenType -//! feature flags, paragraph borders, grid alignment, column rules, custom dashed stroke styles. +//! feature flags, paragraph borders, grid alignment, custom dashed stroke styles; column rule +//! stroke type and overprint. //! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and //! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available), //! clipping paths, compound-path fill rules. diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index e6308e8..850726b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -860,6 +860,52 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } +#[test] +fn imports_column_rules_from_frames_and_object_styles() { + let rule = r#"ColumnRuleOverride="true" ColumnRuleStrokeWidth="2.5" ColumnRuleStrokeColor="Color/Black" ColumnRuleStrokeTint="40" + ColumnRuleStrokeType="StrokeStyle/$ID/Solid" ColumnRuleOverprintOverride="false" ColumnRuleOffset="-3" ColumnRuleTopInset="6" + ColumnRuleBottomInset="9" ColumnRuleInsetChainOverride="false""#; + let styles = format!( + r#""# + ); + let d = inset_fixture(&format!(r#""#), &styles); + let style = d.styles.object.iter().find(|s| s.name == "Padded").unwrap().text_frame.clone().unwrap(); + for o in [d.spreads[0].items[0].text_frame().unwrap().options.clone(), style] { + assert!(o.column_rule, "{o:?}"); + assert_eq!(o.column_rule_weight, 2.5); + assert_eq!(o.column_rule_color, designcraft_color::swatch::BLACK); + assert!((o.column_rule_tint - 0.4).abs() < 1e-6); + assert_eq!((o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset), (-3.0, 6.0, 9.0)); + } + // Absent attributes: no rule, InDesign's defaults. + let d = inset_fixture(r#""#, ""); + assert_eq!(d.spreads[0].items[0].text_frame().unwrap().options, designcraft_doc::TextFrameOptions { columns: 3, ..Default::default() }); +} + +#[test] +fn column_rules_round_trip() { + let mut d = small_doc(); + let fid = d.spreads[0].items[0].id; + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.columns = 2; + o.column_rule = true; + o.column_rule_weight = 0.75; + o.column_rule_color = "Brand".into(); + o.column_rule_tint = 0.5; + o.column_rule_offset = 1.5; + o.column_rule_top_inset = 4.0; + o.column_rule_bottom_inset = 2.0; + let want = o.clone(); + let bytes = export_idml(&d); + let back = import_idml(&bytes).unwrap(); + let got = back.spreads[0].items.iter().find_map(|i| i.text_frame().filter(|t| t.options.column_rule)).unwrap().options.clone(); + assert_eq!(got, want); + // Frames without a rule stay without one. + let plain = export_idml(&small_doc()); + let back = import_idml(&plain).unwrap(); + assert!(back.spreads.iter().flat_map(|s| &s.items).filter_map(|i| i.text_frame()).all(|t| !t.options.column_rule)); +} + /// InDesign's designmap lists layers back to front; `Document::layers[0]` is the front layer. #[test] fn layers_import_and_export_in_stacking_order() { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 18aa5b4..521e444 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -411,3 +411,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()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1e1ae0e..c443555 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -30,7 +30,6 @@ impl Dialog { "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), - "textFrameOptions" => json!({"columns": 1, "gutter": "1p0", "inset": "0p0", "verticalJustification": "top"}), "documentSetup" => json!({}), _ => json!({}), }; @@ -285,6 +284,185 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The options of the selected text frame (the first selected item, or the frame of the caret). +fn selected_frame_options(app: &DesignApp) -> Option { + let st = app.session.active()?; + let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?; + st.doc.item(fid)?.text_frame().map(|t| t.options.clone()) +} + +/// 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. +fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { + if d.fields.contains_key("_shown") { + return; + } + let o = selected_frame_options(app).unwrap_or_default(); + 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), + "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, + "columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset), + "columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset), + }); + if let Value::Object(m) = &shown { + for (k, v) in m { + d.fields.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + d.fields.insert("_shown".into(), shown); +} + +/// A 0..1 tint as a percentage ("40", "12.5"). +fn percent(t: f32) -> String { + let s = format!("{:.2}", f64::from(t) * 100.0); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &'static str| crate::i18n::tr(&lang, s); + egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Number of columns")); + text_field(ui, d, "columns", 80.0); + ui.end_row(); + crate::rtl::label(ui, tr("Gutter")); + text_field(ui, d, "gutter", 80.0); + ui.end_row(); + }); + 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| { + for (label, key) in [("Top", "insetTop"), ("Bottom", "insetBottom"), ("Left", "insetLeft"), ("Right", "insetRight")] { + crate::rtl::label(ui, tr(label)); + text_field(ui, d, key, 80.0); + ui.end_row(); + } + }); + ui.add_space(6.0); + egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Vertical justification")); + let cur = d.s("verticalJustification"); + egui::ComboBox::from_id_salt("vj").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, &cur))).show_ui(ui, |ui| { + for v in ["top", "center", "bottom", "justify"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&lang, v))).clicked() { + d.fields.insert("verticalJustification".into(), json!(v)); + } + } + }); + ui.end_row(); + }); + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(tr("Column Rules")).font(semibold(12.0))); + check(ui, d, "columnRule", tr("Insert Column Rule")); + let swatches: Vec = app + .session + .active() + .map(|s| { + s.doc + .swatches + .iter() + .filter(|w| !matches!(w.value, designcraft_color::swatch::SwatchValue::Gradient { .. })) + .map(|w| w.name.clone()) + .collect() + }) + .unwrap_or_default(); + let opts: Vec<(&str, &str)> = swatches.iter().map(|n| (n.as_str(), n.as_str())).collect(); + egui::Grid::new("tfo_rule").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Weight")); + text_field(ui, d, "columnRuleWeight", 70.0); + ui.end_row(); + crate::rtl::label(ui, tr("Color")); + combo(ui, d, "columnRuleColor", &opts); + ui.end_row(); + for (label, key) in [ + ("Tint %", "columnRuleTint"), + ("Top Inset", "columnRuleTopInset"), + ("Bottom Inset", "columnRuleBottomInset"), + ("Horizontal Offset", "columnRuleOffset"), + ] { + crate::rtl::label(ui, tr(label)); + text_field(ui, d, key, 70.0); + ui.end_row(); + } + }); +} + +/// Whether the user changed `key` from what the dialog showed. +fn edited(d: &Dialog, key: &str) -> bool { + let shown = match d.fields.get("_shown").and_then(|s| s.get(key)) { + Some(Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + None => return d.fields.contains_key(key), + }; + d.fields.contains_key(key) && d.s(key).trim() != shown.trim() +} + +fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { + seed_text_frame_options(app, d); + let d = &*d; + let mut p = Map::new(); + let bad = |what: &str| format!("Text Frame Options: {what}"); + if edited(d, "columns") { + p.insert("columns".into(), json!(d.n("columns").ok_or_else(|| bad("the number of columns is not a number"))?.max(1.0) as u64)); + } + if edited(d, "gutter") { + p.insert("gutter".into(), json!(d.m("gutter").ok_or_else(|| bad("the gutter is not a measure"))?)); + } + let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; + if edited(d, "inset") { + p.insert("inset".into(), json!(d.m("inset").ok_or_else(|| bad("the inset is not a measure"))?)); + } else if sides.iter().any(|k| edited(d, k)) { + // Sides left as shown are null: each selected frame keeps its own. + let mut inset = [None; 4]; + for (v, k) in inset.iter_mut().zip(sides) { + if edited(d, k) { + *v = Some(d.m(k).ok_or_else(|| bad("an inset is not a measure"))?); + } + } + p.insert("inset".into(), json!(inset)); + } + if edited(d, "verticalJustification") { + p.insert("verticalJustification".into(), json!(d.s("verticalJustification"))); + } + if edited(d, "columnRule") { + p.insert("columnRule".into(), json!(d.b("columnRule"))); + } + if edited(d, "columnRuleWeight") { + p.insert("columnRuleWeight".into(), json!(d.pt("columnRuleWeight").ok_or_else(|| bad("the column rule weight is not a measure"))?)); + } + if edited(d, "columnRuleColor") { + p.insert("columnRuleColor".into(), json!(d.s("columnRuleColor"))); + } + if edited(d, "columnRuleTint") { + p.insert("columnRuleTint".into(), json!(d.n("columnRuleTint").ok_or_else(|| bad("the column rule tint is not a number"))? / 100.0)); + } + for (key, what) in [ + ("columnRuleTopInset", "the column rule top inset is not a measure"), + ("columnRuleBottomInset", "the column rule bottom inset is not a measure"), + ("columnRuleOffset", "the column rule offset is not a measure"), + ] { + if edited(d, key) { + p.insert(key.into(), json!(d.m(key).ok_or_else(|| bad(what))?)); + } + } + if p.is_empty() { + return Ok(Value::Null); + } + // Typing in a frame: the options apply to that frame. + if let Some(st) = app.session.active() + && st.selection.items.is_empty() + && let Some(f) = st.selection.text.and_then(|t| t.frame) + { + p.insert("ids".into(), json!([f.0])); + } + app.run("object.textFrameOptions", Value::Object(p)) +} + /// File › Document Setup, filled from the document. pub fn open_document_setup(app: &mut DesignApp) { let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return }; @@ -1362,27 +1540,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "textFrameOptions" => { - egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of columns")); - text_field(ui, &mut d, "columns", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); - text_field(ui, &mut d, "gutter", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inset spacing")); - text_field(ui, &mut d, "inset", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Vertical justification")); - let cur = d.s("verticalJustification"); - egui::ComboBox::from_id_salt("vj").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))).show_ui(ui, |ui| { - for v in ["top", "center", "bottom", "justify"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() { - d.fields.insert("verticalJustification".into(), json!(v)); - } - } - }); - ui.end_row(); - }); + seed_text_frame_options(app, &mut d); + text_frame_options_dialog(app, ui, &mut d); } "documentSetup" => document_setup(app, ui, &mut d), "alert" => { @@ -1476,10 +1635,16 @@ pub fn confirm(app: &mut DesignApp) -> Result { json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), ), - "textFrameOptions" => app.run( - "object.textFrameOptions", - json!({"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "inset": d.m("inset").unwrap_or(0.0), "verticalJustification": d.s("verticalJustification")}), - ), + "textFrameOptions" => { + // On an error the dialog stays open to be corrected. + let mut d = d; + let r = confirm_text_frame_options(app, &mut d); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "documentSetup" => { let edges = |k: &str| json!(["Top", "Bottom", "Inside", "Outside"].map(|e| d.m(&format!("{k}{e}")).unwrap_or(0.0))); app.run( @@ -3494,6 +3659,52 @@ mod tests { assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range")); } + #[test] + fn text_frame_options_change_only_what_was_edited() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + app.run("object.textFrameOptions", json!({"ids": [id.0], "columns": 2, "inset": [1, 2, 3, 4]})).unwrap(); + let opts = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let before = opts(&app); + let open = |app: &mut DesignApp, fields: &[(&str, Value)]| { + app.ui.dialog = Some(Dialog::new("textFrameOptions", json!({}))); + let mut d = app.ui.dialog.take().unwrap(); + seed_text_frame_options(app, &mut d); + for (k, v) in fields { + d.fields.insert((*k).into(), v.clone()); + } + app.ui.dialog = Some(d); + confirm(app) + }; + // OK without edits changes nothing (no undo step either). + assert_eq!(open(&mut app, &[]).unwrap(), Value::Null); + assert_eq!(opts(&app), before); + // The rule's weight is in points; unequal insets the user didn't touch stay. + open(&mut app, &[("columnRule", json!(true)), ("columnRuleWeight", json!("6"))]).unwrap(); + let o = opts(&app); + assert!(o.column_rule && o.column_rule_weight == 6.0, "{o:?}"); + assert_eq!((o.inset, o.columns, o.gutter), (before.inset, before.columns, before.gutter)); + // One of the rule's insets edited: the other rule settings stay. + open(&mut app, &[("columnRuleBottomInset", json!("0p9")), ("columnRuleTint", json!("40"))]).unwrap(); + let o = opts(&app); + assert_eq!((o.column_rule_bottom_inset, o.column_rule_top_inset, o.column_rule_offset, o.column_rule_weight), (9.0, 0.0, 0.0, 6.0)); + assert!((o.column_rule_tint - 0.4).abs() < 1e-6 && o.column_rule); + // 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]); + // 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 { + app.run("edit.undo", json!({})).unwrap(); + } + assert_eq!(opts(&app), before); + } + #[test] fn parses_command_params() { let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5d2db72..1a8d5e1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1260,6 +1260,12 @@ 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", "列宽"]), + ("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 %", "濃淡 %", "色调 %"]), + ("Top Inset", ["Oberer Einzug", "Retrait supérieur", "Sangría superior", "上インセット", "顶部内边距"]), + ("Bottom Inset", ["Unterer Einzug", "Retrait inférieur", "Sangría inferior", "下インセット", "底部内边距"]), + ("Horizontal Offset", ["Horizontaler Versatz", "Décalage horizontal", "Desplazamiento horizontal", "水平オフセット", "水平偏移"]), ("Condition Options…", ["Condition Options…", "Condition Options…", "Condition Options…", "Condition Options…", "条件选项…"]), ( "Convert to Liquid Guide", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 64c1435..024e34d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -442,6 +442,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cell Text", "نص الخلية"), ("Cell Type", "نوع الخلية"), ("Column Width", "عرض العمود"), + ("Column Rules", "خطوط الأعمدة"), + ("Insert Column Rule", "إدراج خط بين الأعمدة"), + ("Tint %", "درجة اللون %"), + ("Top Inset", "الإزاحة العلوية"), + ("Bottom Inset", "الإزاحة السفلية"), + ("Horizontal Offset", "الإزاحة الأفقية"), ("Composer", "منضّد النص"), ("Convert Selection to Multi-State Object", "تحويل التحديد إلى كائن متعدد الحالات"), ("Convert to Text", "تحويل إلى نص"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 7ca1fa9..8325b52 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -441,6 +441,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cell Text", "Texto da célula"), ("Cell Type", "Tipo de célula"), ("Column Width", "Largura da coluna"), + ("Column Rules", "Fios de coluna"), + ("Insert Column Rule", "Inserir fio de coluna"), + ("Tint %", "Matiz %"), + ("Top Inset", "Recuo superior"), + ("Bottom Inset", "Recuo inferior"), + ("Horizontal Offset", "Deslocamento horizontal"), ("Composer", "Compositor"), ("Convert Selection to Multi-State Object", "Converter seleção em objeto de vários estados"), ("Convert to Text", "Converter em texto"),