T: Underline / Strikethrough Options — weight, offset, colour, tint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 15:57:02 -04:00
parent 3a36772023
commit 4aa76b85f9
11 changed files with 163 additions and 32 deletions

View File

@ -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`.

View File

@ -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<Item = (&Rule, Rect)> {
[(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 {

View File

@ -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

View File

@ -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<f64> = None,
underline_offset: Option<f64> = None,
underline_color: String = String::new(),
underline_tint: f32 = 1.0,
/// Strikethrough Options: offset above the baseline.
strikethrough_weight: Option<f64> = None,
strikethrough_offset: Option<f64> = None,
strikethrough_color: String = String::new(),
strikethrough_tint: f32 = 1.0,
ligatures: bool = true,
no_break: bool = false,
language: String = "English: USA".into(),

View File

@ -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"));
}

View File

@ -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)));

View File

@ -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<String> = Vec::new();
let mut any = false;

View File

@ -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);
}
}

View File

@ -25,8 +25,8 @@ pub(crate) struct LineGlyphs {
pub bounds: Option<Rect>,
/// 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);
}
}
}
}

View File

@ -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.

View File

@ -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 |