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"),