From 4aa76b85f9b624bdbe559f700a9f12ffe2cbbdd2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 15:57:02 -0400 Subject: [PATCH] =?UTF-8?q?T:=20Underline=20/=20Strikethrough=20Options=20?= =?UTF-8?q?=E2=80=94=20weight,=20offset,=20colour,=20tint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 23 ++++++++++++++++++ crates/compose/src/shape.rs | 12 ++++++++++ crates/doc/src/attrs.rs | 11 +++++++++ crates/engine/src/tests_idml.rs | 30 ++++++++++++++++++++++++ crates/idml/src/export.rs | 17 ++++++++++++++ crates/idml/src/import.rs | 17 ++++++++++++++ crates/pdf/src/text.rs | 23 +++++++----------- crates/render/src/text.rs | 24 ++++++++----------- crates/ui-egui/src/panels/properties.rs | 31 +++++++++++++++++++++++++ docs/parity.md | 6 ++--- 11 files changed, 163 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5986bcf..3c7544e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). - Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). - File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index d17f807..54daf9e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -46,12 +46,35 @@ pub struct RunStyle { pub stroke_weight: f64, pub underline: bool, pub strikethrough: bool, + /// The underline and strikethrough bars (Underline / Strikethrough Options resolved). + pub underline_rule: Rule, + pub strike_rule: Rule, pub skew: f64, pub size: f64, /// The run's font isn't installed (shown in a substitute; highlighted on screen). pub missing_font: bool, } +/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative = +/// above), thickness and colour. +#[derive(Clone, Debug, PartialEq)] +pub struct Rule { + pub offset: f64, + pub weight: f64, + pub color: String, + pub tint: f32, +} + +impl RunStyle { + /// The bars this run draws between `x0` and `x1` on a line at `baseline`. + pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator { + [(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)] + .into_iter() + .filter(|(on, _)| *on) + .map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight))) + } +} + /// A positioned glyph. `x` is absolute in frame inner space; `y` is relative to the line baseline. #[derive(Clone, Debug)] pub struct PlacedGlyph { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a62640d..f8b1392 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -107,6 +107,18 @@ impl StyleTable<'_> { stroke_weight: p.stroke_weight, underline: p.underline, strikethrough: p.strikethrough, + underline_rule: crate::Rule { + offset: p.underline_offset.unwrap_or(p.size * 0.12), + weight: p.underline_weight.unwrap_or(p.size / 14.0), + color: if p.underline_color.is_empty() { p.fill.clone() } else { p.underline_color.clone() }, + tint: if p.underline_color.is_empty() { p.fill_tint } else { p.underline_tint }, + }, + strike_rule: crate::Rule { + offset: -p.strikethrough_offset.unwrap_or(p.size * 0.3), + weight: p.strikethrough_weight.unwrap_or(p.size / 14.0), + color: if p.strikethrough_color.is_empty() { p.fill.clone() } else { p.strikethrough_color.clone() }, + tint: if p.strikethrough_color.is_empty() { p.fill_tint } else { p.strikethrough_tint }, + }, skew: p.skew, size: p.size, missing_font: *self diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 7836658..e8791a2 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -388,6 +388,17 @@ attr_set! { position: Position = Position::Normal, underline: bool = false, strikethrough: bool = false, + /// Underline Options: weight and offset below the baseline (`None` = from the size), + /// colour (empty = the text colour) and tint. + underline_weight: Option = None, + underline_offset: Option = None, + underline_color: String = String::new(), + underline_tint: f32 = 1.0, + /// Strikethrough Options: offset above the baseline. + strikethrough_weight: Option = None, + strikethrough_offset: Option = None, + strikethrough_color: String = String::new(), + strikethrough_tint: f32 = 1.0, ligatures: bool = true, no_break: bool = false, language: String = "English: USA".into(), diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index d18c7a3..11a5a46 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -152,3 +152,33 @@ fn open_type_features_round_trip_through_idml() { let after = st.format_after(5).over.otf_features.clone().unwrap_or_default(); assert!(!designcraft_doc::otf::is_on(&after, "frac") && designcraft_doc::otf::stylistic_sets(&after) == 0, "{after:?}"); } + +#[test] +fn underline_options_render_and_round_trip() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Underlined words"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"underline": true, "underlineWeight": 3, "underlineOffset": 4, "underlineColor": "C=100 M=0 Y=0 K=0"}})) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); + let st = cs.styles.iter().find(|st| st.underline).unwrap(); + assert_eq!((st.underline_rule.weight, st.underline_rule.offset), (3.0, 4.0)); + assert_eq!(st.underline_rule.color, "C=100 M=0 Y=0 K=0"); + // Drawn in cyan, 4–7 pt under the baseline. + let l = &cs.frames[0].lines[0]; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let x = l.glyphs[2].x as u32; + let p = img.pixel(x, (l.baseline + 5.5) as u32); + assert!(p[0] < 80 && p[2] > 150, "cyan underline: {p:?}"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap(); + let story = back.stories.values().find(|x| x.text.starts_with("Underlined")).unwrap(); + let f = story.format_after(0); + assert_eq!(f.over.underline_weight, Some(Some(3.0))); + assert_eq!(f.over.underline_offset, Some(Some(4.0))); + assert_eq!(f.over.underline_color.as_deref(), Some("C=100 M=0 Y=0 K=0")); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 2dfbf69..cec20a4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -875,6 +875,23 @@ impl<'a> Ex<'a> { if let Some(v) = a.ligatures { el.set("Ligatures", bool_s(v)); } + for (k, w, o, c, t) in [ + ("Underline", a.underline_weight, a.underline_offset, &a.underline_color, a.underline_tint), + ("StrikeThru", a.strikethrough_weight, a.strikethrough_offset, &a.strikethrough_color, a.strikethrough_tint), + ] { + if let Some(w) = w { + el.set(&format!("{k}Weight"), num(w.unwrap_or(-9999.0))); + } + if let Some(o) = o { + el.set(&format!("{k}Offset"), num(o.unwrap_or(-9999.0))); + } + if let Some(c) = c.as_ref().filter(|c| !c.is_empty()) { + el.set(&format!("{k}Color"), self.sw(c)); + } + if let Some(t) = t { + el.set(&format!("{k}Tint"), num(t as f64 * 100.0)); + } + } if let Some(list) = &a.otf_features { for (tag, _, attr) in designcraft_doc::otf::TOGGLES { el.set(attr, bool_s(designcraft_doc::otf::is_on(list, tag))); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f06d797..4c3387b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -859,6 +859,23 @@ impl<'r> Importer<'r> { a.underline = e.boolean("Underline"); a.strikethrough = e.boolean("StrikeThru"); a.ligatures = e.boolean("Ligatures"); + // Underline / strikethrough options (-9999 = automatic). + for k in ["Underline", "StrikeThru"] { + let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0); + let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty()); + let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32); + if k == "Underline" { + a.underline_weight = num("Weight").map(Some); + a.underline_offset = num("Offset").map(Some); + a.underline_color = color; + a.underline_tint = tint; + } else { + a.strikethrough_weight = num("Weight").map(Some); + a.strikethrough_offset = num("Offset").map(Some); + a.strikethrough_color = color; + a.strikethrough_tint = tint; + } + } // OpenType features (only when the element sets any). let mut otf_list: Vec = Vec::new(); let mut any = false; diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index cb9521e..0057c51 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -64,7 +64,7 @@ impl Exporter<'_> { if !ft.tables.is_empty() { self.tables(s, ft); } - let mut deco: Vec<(u32, Rect)> = Vec::new(); + let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); for l in &ft.lines { let gs = &l.glyphs; let mut i = 0; @@ -84,25 +84,18 @@ impl Exporter<'_> { // Underline / strikethrough, merged per style along the line. for g in gs.iter().filter(|g| g.visible) { let st = &cs.styles[g.style as usize]; - let w = st.size / 14.0; - let mut add = |y: f64| { - let r = Rect::new(g.x, y, g.x + g.adv, y + w); + for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) { match deco.last_mut() { - Some((si, last)) if *si == g.style && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => last.x1 = r.x1, - _ => deco.push((g.style, r)), + Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => { + last.x1 = r.x1 + } + _ => deco.push((rule.clone(), r)), } - }; - if st.underline { - add(l.baseline + st.size * 0.12); - } - if st.strikethrough { - add(l.baseline - st.size * 0.3); } } } - for (si, r) in deco { - let st = &cs.styles[si as usize]; - if let Some(c) = self.swatch_color(&st.fill, st.fill_tint) { + for (rule, r) in deco { + if let Some(c) = self.swatch_color(&rule.color, rule.tint) { self.fill_rect(s, r, c); } } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fb5a39b..55abeaa 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -25,8 +25,8 @@ pub(crate) struct LineGlyphs { pub bounds: Option, /// Glyph outlines batched per run style. pub runs: Vec<(u32, BezPath)>, - /// Underline / strikethrough bars. - pub decos: Vec<(u32, Rect)>, + /// Underline / strikethrough bars: colour swatch, tint, rectangle. + pub decos: Vec<(String, f32, Rect)>, pub glyphs: usize, } @@ -123,14 +123,8 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line) -> LineGlyphs { continue; } let style = &cs.styles[g.style as usize]; - if style.underline || style.strikethrough { - let w = style.size / 14.0; - if style.underline { - decos.push((g.style, Rect::new(g.x, l.baseline + style.size * 0.12, g.x + g.adv, l.baseline + style.size * 0.12 + w))); - } - if style.strikethrough { - decos.push((g.style, Rect::new(g.x, l.baseline - style.size * 0.3, g.x + g.adv, l.baseline - style.size * 0.3 + w))); - } + for (rule, r) in style.rules(g.x, g.x + g.adv, l.baseline) { + decos.push((rule.color.clone(), rule.tint, r)); } let outline = db.outline(&g.face, g.gid); if outline.elements().is_empty() { @@ -156,7 +150,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line) -> LineGlyphs { let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 }; bounds = union(bounds, bp.bounding_box().inflate(sw, sw)); } - for (_, r) in &decos { + for (_, _, r) in &decos { bounds = union(bounds, *r); } LineGlyphs { bounds, runs, decos, glyphs } @@ -263,9 +257,11 @@ impl Renderer { ctx.stroke_path(bp); } } - for (si, r) in &lg.decos { - ctx.set_paint(fill_of(*si).unwrap_or(peniko::Color::BLACK)); - ctx.fill_rect(r); + for (sw, tint, r) in &lg.decos { + if let Some(c) = doc.resolve_color(sw, *tint) { + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(r); + } } } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 0e9a1b4..fff2a2e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1188,6 +1188,37 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); ui.menu_button("OpenType", |ui| open_type_menu(app, ui, &features)); }); + // Underline / Strikethrough Options. + for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] { + if !c[key].as_bool().unwrap_or(false) { + continue; + } + egui::CollapsingHeader::new(title).id_salt(key).show(ui, |ui| { + let size = c["size"].as_f64().unwrap_or(12.0); + let auto_off = if key == "underline" { size * 0.12 } else { size * 0.3 }; + egui::Grid::new((key, "opts")).num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { + caption(ui, "Weight"); + let w = c[format!("{key}Weight")].as_f64().unwrap_or(size / 14.0); + if let Some(v) = number(ui, &format!("{key}w"), Some(w), " pt", 60.0, 2) { + let _ = app.run("type.char", json!({"attrs": {format!("{key}Weight"): v.max(0.0)}})); + } + caption(ui, "Offset"); + let o = c[format!("{key}Offset")].as_f64().unwrap_or(auto_off); + if let Some(v) = number(ui, &format!("{key}o"), Some(o), " pt", 60.0, 2) { + let _ = app.run("type.char", json!({"attrs": {format!("{key}Offset"): v}})); + } + ui.end_row(); + }); + ui.horizontal(|ui| { + caption(ui, "Color"); + let cur = c[format!("{key}Color")].as_str().filter(|s| !s.is_empty()).map(str::to_string); + super::swatch_picker(app, ui, &format!("{key}c"), cur.clone(), |app, n| { + let _ = app.run("type.char", json!({"attrs": {format!("{key}Color"): n}})); + }); + ui.label(cur.unwrap_or_else(|| "(Text Color)".into())); + }); + }); + } } /// The Character panel's OpenType menu: feature toggles, figure styles and stylistic sets. diff --git a/docs/parity.md b/docs/parity.md index 9c6e65a..6e8dbd1 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 47 | 23 | 27 | 60% | +| P1 | 97 | 48 | 23 | 26 | 61% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Effects & transparency | 50% | | Placing & links | 78% | | Type & text frames | 80% | -| Typography | 79% | +| Typography | 83% | | Styles | 71% | | Tables | 73% | | Long documents | 58% | @@ -216,7 +216,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Type & text frames | Math expressions | P2 | M | | Type & text frames | Right-to-left / World-Ready composer | P2 | M | | Typography | Character attributes | P0 | D | -| Typography | Underline/Strikethrough options | P1 | M | +| Typography | Underline/Strikethrough options | P1 | D | | Typography | OpenType features | P0 | D | | Typography | Variable fonts | P1 | M | | Typography | Paragraph attributes | P0 | D |