Browse / github.com/storytold/filmcraft / patch
feat(text): strikethrough for graphic text
feat(text): strikethrough for graphic text Graphic text could be underlined but not struck through. This adds strikethrough, per layer and per character. Premiere Pro's Essential Graphics has no strikethrough as far as I know, so this goes beyond parity. I missed it while actually editing with FilmCraft (crossing out a word in an on-screen title). ## What changes - **Per layer:** new `strikethrough` text-layer parameter. Properties ▸ Text ▸ Style gets an "S" button with a stroke, next to "U". - **Per character:** `graphics.setCharStyle` accepts `strikethrough` (alias `strike`). It is stored in the layer's style runs (`CharStyle::str
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- alexanderalber (imported from pull request #3433, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +235 −31 in 25 files
- Review
- pending
- Shared
- 2026-10-28
- Id
321a62a449946d073b3fd9f2
How it went
No other operator has reported using it yet.
Diff
raw · contributors/people.toml crates/engine/src/graphic_templates.rs crates/engine/src/graphic_templates/tests.rs crates/project/src/graphic.rs crates/project/src/graphic_design.rs crates/render/src/graphic_clip.rs crates/render/tests/it/main.rs crates/render/tests/it/strikethrough.rs crates/text/README.md crates/text/src/fonts.rs crates/text/src/layout.rs crates/text/src/layout/tests.rs crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/zh-cn.tsv crates/ui-egui/src/panels/graphics.rs crates/ui-egui/src/panels/graphics/layer_identity.rs crates/ui-egui/src/panels/graphics/properties.rs docs/graphics.md
@@ -88,3 +88,6 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear"++[people.alexanderalber]+real_name = "Alexander Alber"@@ -763,6 +763,7 @@ fn char_style_p(v: &Value) -> Result<CharStyle> { "bold" | "fauxBold" | "faux_bold" => c.faux_bold = x.as_bool(), "italic" | "fauxItalic" | "faux_italic" => c.faux_italic = x.as_bool(), "underline" => c.underline = x.as_bool(),+ "strikethrough" | "strike" => c.strikethrough = x.as_bool(), "tracking" => c.tracking = x.as_f64().map(|v| v as f32), "baselineShift" | "baseline_shift" => c.baseline_shift = x.as_f64().map(|v| v as f32), "caps" => {@@ -1236,7 +1237,7 @@ pub fn commands() -> Vec<CommandSpec> { "graphics.setCharStyle", "Character Style", &[],- r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range),"merge":bool?,"begin":bool? (start a new undo gesture)}"##,+ r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"strikethrough":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range),"merge":bool?,"begin":bool? (start a new undo gesture)}"##, has_graphic, set_char_style, ),@@ -285,6 +285,29 @@ fn character_styles_follow_edits() { assert!(s.execute("graphics.setCharStyle", json!({"clip": c.0, "style": {"sparkle": true}})).is_err()); } +#[test]+fn strikethrough_per_character_and_per_layer() {+ let mut s = demo("gt-strike");+ let c = clip_of(&s.execute("graphics.newText", json!({"text": "old new"})).unwrap());+ s.execute("graphics.setCharStyle", json!({"clip": c.0, "start": 0, "end": 3, "style": {"strikethrough": true}})).unwrap();+ let st = s.execute("graphics.list", json!({"clip": c.0})).unwrap()["layers"][0]["styles"].clone();+ assert_eq!(st, json!([{"start": 0, "end": 3, "strikethrough": true}]));+ let layout = |s: &Session| {+ let it = item(s, c);+ let sp = &item_layer_specs(&it, it.source_in, canvas(s))[0].1;+ let LayerContent::Text(t) = &sp.content else { panic!() };+ filmcraft_render::graphic_clip::text_layout(t)+ };+ let l = layout(&s);+ assert_eq!(l.underline_runs, vec![1], "one stroke, over the styled word");+ assert!(l.underlines[0][2] <= l.caret(3).0 + 0.01);+ s.execute("graphics.setCharStyle", json!({"clip": c.0, "clear": true})).unwrap();+ s.execute("graphics.set", json!({"clip": c.0, "props": {"strikethrough": true}})).unwrap();+ let l = layout(&s);+ assert_eq!(l.underlines.len(), 1);+ assert!((l.underlines[0][2] - l.underlines[0][0] - l.lines[0].width).abs() < 0.01, "the whole line");+}+ #[test] fn upgrade_caption_to_graphic() { let mut s = demo("gt-caption");@@ -170,6 +170,7 @@ pub(crate) fn layer_defs() -> Vec<EffectDef> { bo("faux_italic", "Faux Italic", false, X), chc("caps", "Capitalisation", CAPS_OPTS, 0, X), bo("underline", "Underline", false, X),+ bo("strikethrough", "Strikethrough", false, X), fl("box_width", "Text Box Width", 0.0, (0.0, 100_000.0), (0.0, 3840.0), "", X), fl("box_height", "Text Box Height", 0.0, (0.0, 100_000.0), (0.0, 2160.0), "", X), bo("vertical", "Vertical Text", false, X),@@ -251,6 +252,7 @@ pub struct TextProps { /// 0 normal, 1 all caps, 2 small caps. pub caps: u32, pub underline: bool,+ pub strikethrough: bool, /// Paragraph-text box width (0 = point text). pub box_width: f32, /// Paragraph-text box height: lines that do not fit are not shown (0 = as tall as the text).@@ -346,6 +348,7 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<Laye faux_italic: bb(e, "faux_italic", t), caps: ch(e, "caps", t), underline: bb(e, "underline", t),+ strikethrough: bb(e, "strikethrough", t), box_width: ff(e, "box_width", t).max(0.0), box_height: ff(e, "box_height", t).max(0.0), vertical: bb(e, "vertical", t),@@ -1,7 +1,7 @@ //! Responsive design and per-character styles of graphic clips (M10.7). //! //! - **Per-character styles** ([`StyleRun`], [`CharStyle`]): a text layer's runs override font,-//! style, size, fill, faux bold / italic, underline, tracking, baseline shift and caps for a+//! style, size, fill, faux bold / italic, underline, strikethrough, tracking, baseline shift and caps for a //! range of characters. Ranges are in *characters* (not bytes), sorted and non-overlapping; //! [`apply_char_style`] merges a new style into a range and [`adjust_runs`] keeps runs on the //! right characters when the text is edited (typed characters take the style of the character@@ -69,6 +69,8 @@ pub struct CharStyle { pub faux_italic: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub underline: Option<bool>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub strikethrough: Option<bool>, /// Tracking in 1/1000 em. #[serde(skip_serializing_if = "Option::is_none")] pub tracking: Option<f32>,@@ -94,6 +96,7 @@ impl CharStyle { faux_bold: over.faux_bold.or(self.faux_bold), faux_italic: over.faux_italic.or(self.faux_italic), underline: over.underline.or(self.underline),+ strikethrough: over.strikethrough.or(self.strikethrough), tracking: over.tracking.or(self.tracking), baseline_shift: over.baseline_shift.or(self.baseline_shift), caps: over.caps.or(self.caps),@@ -49,6 +49,7 @@ pub fn text_styles(t: &TextProps) -> (TextStyle, ParagraphStyle) { faux_italic: t.faux_italic, caps: caps_of(t.caps), underline: t.underline,+ strikethrough: t.strikethrough, }, ParagraphStyle { align: match t.align {@@ -91,6 +92,7 @@ pub fn text_runs(t: &TextProps, base: &TextStyle) -> Vec<StyleRun> { faux_italic: c.faux_italic.unwrap_or(base.faux_italic), caps: c.caps.map_or(base.caps, caps_of), underline: c.underline.unwrap_or(base.underline),+ strikethrough: c.strikethrough.unwrap_or(base.strikethrough), ..base.clone() }, })@@ -1,4 +1,5 @@ mod median_radius; mod noise_frame_cadence; mod sharpen_preview_res;+mod strikethrough; mod underline_direction;new file mode 100644@@ -0,0 +1,49 @@+//! Graphic text with strikethrough (letter o, 60 px): the stroke crosses the letter.+use filmcraft_geom::{Affine, Vec2};+use filmcraft_project::graphic::{new_text_layer, new_vertical_text_layer};+use filmcraft_project::{EffectInstance, ParamValue};+use filmcraft_render::graphic_clip::render_graphic_layers;+use filmcraft_render::image::Image;+use filmcraft_time::Tick;++fn set(e: &mut EffectInstance, k: &str, v: ParamValue) {+ e.params.get_mut(k).unwrap_or_else(|| panic!("{k}")).value = v;+}++fn draw(layer: EffectInstance) -> Image {+ let mut img = Image::new(320, 200);+ render_graphic_layers(&[layer], Tick::ZERO, (320, 200), &Affine::IDENTITY, &mut img);+ img+}++fn ink(img: &Image) -> Vec<(usize, usize)> {+ (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.4).collect()+}++fn ink_box(img: &Image) -> [usize; 4] {+ ink(img).into_iter().fold([usize::MAX, usize::MAX, 0, 0], |b, (x, y)| [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)])+}++#[test]+fn strikethrough_crosses_the_letter() {+ let mut h = new_text_layer("o", Vec2::new(80.0, 100.0), 60.0);+ let h_plain = draw(h.clone());+ set(&mut h, "strikethrough", ParamValue::Bool(true));+ let h_st = draw(h);+ let mut v = new_vertical_text_layer("o", Vec2::new(200.0, 100.0), 60.0);+ let v_plain = draw(v.clone());+ set(&mut v, "strikethrough", ParamValue::Bool(true));+ let v_st = draw(v);++ let (hp, hs) = (ink_box(&h_plain), ink_box(&h_st));+ assert!(ink(&h_st).len() > ink(&h_plain).len() + 20, "horizontal strikethrough adds ink");+ assert!(hs[1] >= hp[1] && hs[3] <= hp[3], "the stroke stays within the letter's height: plain {hp:?} struck {hs:?}");+ // the counter of the o is empty in its middle row; the stroke fills it+ let cx = (hp[0] + hp[2]) / 2;+ let row = (hp[1]..=hp[3]).filter(|&y| h_st.get(cx, y)[3] > 0.4 && h_plain.get(cx, y)[3] <= 0.4).count();+ assert!(row > 0, "ink inside the counter");++ let (vp, vs) = (ink_box(&v_plain), ink_box(&v_st));+ assert!(ink(&v_st).len() > ink(&v_plain).len() + 20, "vertical strikethrough adds ink");+ assert!(vs[0] >= vp[0] && vs[2] <= vp[2], "the vertical stroke stays within the letter's width: plain {vp:?} struck {vs:?}");+}@@ -7,8 +7,8 @@ and the Timecode / Clip Name effects. Pure Rust; it builds for `wasm32-unknown-u | Module | What | |---|---| | `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks: bundled faces first, then the optional craft-fonts faces (`CRAFT_FONTS`, embedded by `build.rs` when built with `CRAFT_FONTS_DIR`; Mincho for serif text, Gothic otherwise, nearest weight), then loaded system faces. |-| `layout` | Paragraph layout: per-character font fallback → bidi levels (UAX #9) → items (runs of one face, direction and size) shaped with harfrust (OpenType kerning, ligatures, marks, complex scripts) → tracking → line breaking at UAX #14 opportunities (area text) → visual reordering per line → alignment (left / centre / right / justify), leading (added to the natural line height), baseline shift, all caps / synthesised small caps (78 %), underline. Every character boundary gets a caret stop (ligatures are split evenly), so editors can place carets, hit-test and draw selections. Layouts are cached by text + style (512 entries, LRU). |-| `layout_rich` | Per-character style runs (`StyleRun`: a byte range with its own `TextStyle`): runs split shaping items (font, size, tracking, caps, faux styles), baseline shift and underline apply per run, a line holding a larger run is pushed down from the line above (baseline distance = max(base line height, previous descent + this line's ascent + leading)), and every glyph / underline carries its style index so renderers can colour runs (`render::draw_run`). |+| `layout` | Paragraph layout: per-character font fallback → bidi levels (UAX #9) → items (runs of one face, direction and size) shaped with harfrust (OpenType kerning, ligatures, marks, complex scripts) → tracking → line breaking at UAX #14 opportunities (area text) → visual reordering per line → alignment (left / centre / right / justify), leading (added to the natural line height), baseline shift, all caps / synthesised small caps (78 %), underline, strikethrough (OS/2 strikeout metrics; half the x-height when a font leaves them out). Every character boundary gets a caret stop (ligatures are split evenly), so editors can place carets, hit-test and draw selections. Layouts are cached by text + style (512 entries, LRU). |+| `layout_rich` | Per-character style runs (`StyleRun`: a byte range with its own `TextStyle`): runs split shaping items (font, size, tracking, caps, faux styles), baseline shift, underline and strikethrough apply per run, a line holding a larger run is pushed down from the line above (baseline distance = max(base line height, previous descent + this line's ascent + leading)), and every glyph / underline / strikethrough carries its style index so renderers can colour runs (`render::draw_run`). | | `raster` | Our anti-aliased rasteriser (non-zero winding, 5 sub-scanlines, exact horizontal coverage, active-edge list), contour paths with transforms, rounded rectangles, ellipses, polygons and outline emboldening. Coverage is linear, so compositing in linear light is correct. | | `render` | Glyph outlines (cached per face + glyph) and glyph masks (cached per size in ⅛ px, ¼-px horizontal phase, synthetic style). Axis-aligned uniform transforms use the mask cache with sub-pixel x positioning; rotated / skewed / non-uniform transforms fill the transformed outlines of the whole run in one pass. Synthetic bold grows outlines by 2.5 % of the size; synthetic italic slants by 12°. | | `mask` | Outer / centre / inner strokes from an exact Euclidean distance transform (Felzenszwalb–Huttenlocher) with sub-pixel refinement, a three-pass box blur (≈ Gaussian) for shadows, offsets. |@@ -201,7 +201,7 @@ impl FaceBytes { } /// Vertical metrics at a pixel size (all positive distances, y down from the baseline for the-/// underline position).+/// underline and strikethrough positions, which are the top edges of their strokes). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct VMetrics { pub ascent: f32,@@ -211,6 +211,8 @@ pub struct VMetrics { pub x_height: f32, pub underline_pos: f32, pub underline_thickness: f32,+ pub strikethrough_pos: f32,+ pub strikethrough_thickness: f32, } impl Face {@@ -284,18 +286,27 @@ impl Face { x_height: px * 0.5, underline_pos: px * 0.1, underline_thickness: px * 0.06,+ strikethrough_pos: -px * 0.28,+ strikethrough_thickness: px * 0.06, ..Default::default() }; }; let m = f.metrics(Size::new(px), LocationRef::default());+ let x_height = m.x_height.unwrap_or(m.ascent * 0.5);+ // OS/2 strikeout: offset of the stroke's top above the baseline. Fonts that leave it zero+ // (or put it below the baseline) get a stroke centred on half the x-height.+ let strike_t = m.strikeout.map_or(px * 0.06, |d| d.thickness).max(px * 0.03);+ let strike_top = m.strikeout.map(|d| d.offset).filter(|o| *o > 0.0 && o.is_finite()).unwrap_or(x_height * 0.5 + strike_t * 0.5); VMetrics { ascent: m.ascent, descent: -m.descent, line_gap: m.leading, cap_height: m.cap_height.unwrap_or(m.ascent * 0.7),- x_height: m.x_height.unwrap_or(m.ascent * 0.5),+ x_height, underline_pos: m.underline.map_or(px * 0.1, |u| -u.offset), underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)),+ strikethrough_pos: -strike_top,+ strikethrough_thickness: strike_t, } } /// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`).@@ -1,9 +1,10 @@ //! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning, //! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,-//! tracking, baseline shift, all caps / small caps, underline. Results are cached.+//! tracking, baseline shift, all caps / small caps, underline, strikethrough. Results are cached. //! //! **Per-character styles** ([`layout_rich`]): [`StyleRun`]s give byte ranges their own-//! [`TextStyle`] (font, size, tracking, baseline shift, faux bold / italic, caps, underline).+//! [`TextStyle`] (font, size, tracking, baseline shift, faux bold / italic, caps, underline,+//! strikethrough). //! Runs split shaping items, lines grow to fit the largest run, and every [`Glyph`] carries the //! index of the style it was set in (`run`: 0 = the base style, `i + 1` = `runs[i]`), so renderers //! can colour runs.@@ -60,6 +61,7 @@ pub struct TextStyle { pub faux_italic: bool, pub caps: Caps, pub underline: bool,+ pub strikethrough: bool, } impl Default for TextStyle {@@ -76,6 +78,7 @@ impl Default for TextStyle { faux_italic: false, caps: Caps::Normal, underline: false,+ strikethrough: false, } } }@@ -109,7 +112,7 @@ impl Hash for TextStyle { hf(h, self.size); hf(h, self.tracking); hf(h, self.baseline_shift);- (self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);+ (self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.strikethrough).hash(h); } } @@ -208,9 +211,9 @@ pub struct Line { pub struct Layout { pub glyphs: Vec<Glyph>, pub lines: Vec<Line>,- /// Underline rectangles `[x0, y0, x1, y1]`.+ /// Decoration rectangles `[x0, y0, x1, y1]`: per line its underlines, then its strikethroughs. pub underlines: Vec<[f32; 4]>,- /// Style index of each underline (parallel to `underlines`; see [`Glyph::run`]).+ /// Style index of each decoration (parallel to `underlines`; see [`Glyph::run`]). pub underline_runs: Vec<u16>, /// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width). pub bounds: [f32; 4],@@ -374,6 +377,8 @@ struct ParaLine { descent: f32, /// Underlined segments `(x0, x1, style)` in line coordinates (rich layouts only). underlines: Vec<(f32, f32, u16)>,+ /// Struck-through segments, like `underlines`.+ strikes: Vec<(f32, f32, u16)>, } /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.@@ -391,6 +396,7 @@ fn paragraph(text: &str, base: usize, sty: &Styles, para: &ParagraphStyle) -> Ve ascent: pm.ascent, descent: pm.descent, underlines: Vec::new(),+ strikes: Vec::new(), }]; } let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });@@ -563,25 +569,32 @@ fn paragraph(text: &str, base: usize, sty: &Styles, para: &ParagraphStyle) -> Ve carets.push((base + b, x)); } }- // underlined segments of rich layouts: clusters set in an underlined style, merged- let mut underlines: Vec<(f32, f32, u16)> = Vec::new();- if sty.rich() {+ // decorated segments of rich layouts: clusters set in an underlined (struck-through)+ // style, merged+ let segments = |on: fn(&TextStyle) -> bool| {+ let mut merged: Vec<(f32, f32, u16)> = Vec::new();+ if !sty.rich() {+ return merged;+ } let mut segs: Vec<(f32, f32, u16)> = cluster_starts .iter() .filter(|&&cs| cs < ce) .filter_map(|&cs| { let si = sty.at(base + cs);- sty.list[si].underline.then(|| (extents[&cs].0, extents[&cs].1, si as u16))+ on(sty.list[si]).then(|| (extents[&cs].0, extents[&cs].1, si as u16)) }) .collect(); segs.sort_by(|a, b| a.0.total_cmp(&b.0)); for sg in segs {- match underlines.last_mut() {+ match merged.last_mut() { Some(l) if l.2 == sg.2 && (sg.0 - l.1).abs() < 0.01 => l.1 = l.1.max(sg.1),- _ => underlines.push(sg),+ _ => merged.push(sg), } }- }+ merged+ };+ let underlines = segments(|s| s.underline);+ let strikes = segments(|s| s.strikethrough); // end of line caret let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) { Some(c) => {@@ -621,6 +634,7 @@ fn paragraph(text: &str, base: usize, sty: &Styles, para: &ParagraphStyle) -> Ve ascent: asc, descent: desc, underlines,+ strikes, }); } out@@ -689,10 +703,24 @@ pub fn layout_rich_uncached(text: &str, style: &TextStyle, runs: &[StyleRun], pa lay.underlines.push([x0 + shift, y, x1 + shift, y + um.underline_thickness]); lay.underline_runs.push(*si); }- } else if style.underline && pl.content > 0.0 {- let y = baseline - style.baseline_shift + pm.underline_pos;- lay.underlines.push([x, y, x + pl.content, y + pm.underline_thickness]);- lay.underline_runs.push(0);+ for (x0, x1, si) in &pl.strikes {+ let st = sty.list[*si as usize];+ let sm = fonts::face(sty.prim[*si as usize].face).metrics(st.size);+ let y = baseline - st.baseline_shift + sm.strikethrough_pos;+ lay.underlines.push([x0 + shift, y, x1 + shift, y + sm.strikethrough_thickness]);+ lay.underline_runs.push(*si);+ }+ } else if pl.content > 0.0 {+ if style.underline {+ let y = baseline - style.baseline_shift + pm.underline_pos;+ lay.underlines.push([x, y, x + pl.content, y + pm.underline_thickness]);+ lay.underline_runs.push(0);+ }+ if style.strikethrough {+ let y = baseline - style.baseline_shift + pm.strikethrough_pos;+ lay.underlines.push([x, y, x + pl.content, y + pm.strikethrough_thickness]);+ lay.underline_runs.push(0);+ } } lay.lines.push(Line { range: pl.range,@@ -771,7 +799,8 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, } g }));- // Underline runs down the column, to the right of the cell (the "below" side of vertical-rl).+ // Underline runs down the column, to the right of the cell (the "below" side of vertical-rl);+ // strikethrough runs down the middle of the cell. let y_top = baseline - pl.ascent; if rich { for (_x0, x1, si) in &pl.underlines {@@ -781,10 +810,25 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, lay.underlines.push([x, y_top, x + um.underline_thickness, y_top + row]); lay.underline_runs.push(*si); }- } else if style.underline && pl.content > 0.0 {- let x = shift + pl.left_trim + pl.content + pm.underline_pos;- lay.underlines.push([x, y_top, x + pm.underline_thickness, y_top + row]);- lay.underline_runs.push(0);+ for (x0, x1, si) in &pl.strikes {+ let st = sty.list[*si as usize];+ let t = fonts::face(sty.prim[*si as usize].face).metrics(st.size).strikethrough_thickness;+ let x = (x0 + x1 - t) / 2.0 + shift - st.baseline_shift;+ lay.underlines.push([x, y_top, x + t, y_top + row]);+ lay.underline_runs.push(*si);+ }+ } else if pl.content > 0.0 {+ if style.underline {+ let x = shift + pl.left_trim + pl.content + pm.underline_pos;+ lay.underlines.push([x, y_top, x + pm.underline_thickness, y_top + row]);+ lay.underline_runs.push(0);+ }+ if style.strikethrough {+ let t = pm.strikethrough_thickness;+ let x = shift + pl.left_trim + (pl.content - t) / 2.0;+ lay.underlines.push([x, y_top, x + t, y_top + row]);+ lay.underline_runs.push(0);+ } } lay.lines.push(Line { range: pl.range,@@ -188,6 +188,50 @@ fn vertical_underline_runs_down_the_column() { assert_eq!(rich.underline_runs, vec![1]); } +#[test]+fn strikethrough_crosses_the_glyphs() {+ let s = TextStyle { strikethrough: true, ..st(60.0) };+ let l = layout("Hx", &s, &ParagraphStyle::default());+ assert_eq!(l.underlines.len(), 1);+ let [x0, y0, x1, y1] = l.underlines[0];+ let line = &l.lines[0];+ assert!((x0 - line.x).abs() < 0.01 && (x1 - line.x - line.width).abs() < 0.01, "spans the line");+ // above the baseline (y down) and below the x-height+ let xh = fonts::face(fonts::resolve(&s.family, &s.style).face).metrics(60.0).x_height;+ assert!(y1 < line.baseline && y0 > line.baseline - xh, "{y0} {y1} baseline {} x-height {xh}", line.baseline);+ assert!(y1 - y0 > 0.5, "has a thickness");+ // with underline: two strips, the underline below the baseline+ let both = layout("Hx", &TextStyle { underline: true, ..s.clone() }, &ParagraphStyle::default());+ assert_eq!(both.underlines.len(), 2);+ assert!(both.underlines[0][1] > line.baseline && both.underlines[1][3] < line.baseline);+ // vertical: a tall strip through the middle of the cell+ let v = layout("A", &s, &ParagraphStyle { vertical: true, ..Default::default() });+ let [vx0, vy0, vx1, vy1] = v.underlines[0];+ let vl = &v.lines[0];+ assert!(vy1 - vy0 > vx1 - vx0, "vertical strikethrough is a tall strip");+ assert!(((vx0 + vx1) / 2.0 - (vl.x + vl.width / 2.0)).abs() < 0.5, "centred on the cell");+}++#[test]+fn style_runs_strike_through_only_their_range() {+ let base = st(40.0);+ let runs = [StyleRun { range: 3..5, style: TextStyle { strikethrough: true, ..base.clone() } }];+ let l = layout_rich("ab cd ef", &base, &runs, &ParagraphStyle::default());+ assert_eq!(l.underlines.len(), 1, "only the struck-through run");+ assert_eq!(l.underline_runs, vec![1]);+ let u = l.underlines[0];+ assert!(u[0] >= l.caret(3).0 - 0.01 && u[2] <= l.caret(5).0 + 0.01, "{u:?}");+ assert!(u[3] < l.lines[0].baseline, "above the baseline");+ let v = layout_rich(+ "ab",+ &base,+ &[StyleRun { range: 0..1, style: TextStyle { strikethrough: true, ..base.clone() } }],+ &ParagraphStyle { vertical: true, ..Default::default() },+ );+ assert_eq!(v.underlines.len(), 1);+ assert_eq!(v.underline_runs, vec![1]);+}+ #[test] fn vertical_cells_keep_combining_marks_with_their_base() { let style = st(32.0);@@ -1075,6 +1075,7 @@ All Caps Großbuchstaben Small Caps Kapitälchen Underline Unterstrichen+ Strikethrough Durchgestrichen OpenType OpenType Kerning Unterschneidung Ligatures Ligaturen@@ -1048,6 +1048,7 @@ All Caps Todo mayúsculas Small Caps Versalitas Underline Subrayado+ Strikethrough Tachado OpenType OpenType Kerning Ajuste entre pares Ligatures Ligaduras@@ -1040,6 +1040,7 @@ All Caps Tout en capitales Small Caps Petites capitales Underline Souligné+ Strikethrough Barré OpenType OpenType Kerning Crénage Ligatures Ligatures@@ -1045,6 +1045,7 @@ All Caps Semua Kapital Small Caps Kapital Kecil Underline Garis Bawah+ Strikethrough Coret OpenType OpenType Kerning Kerning Ligatures Ligatur@@ -1037,6 +1037,7 @@ All Caps Tutto maiuscolo Small Caps Maiuscoletto Underline Sottolineato+ Strikethrough Barrato OpenType OpenType Kerning Crenatura Ligatures Legature@@ -1042,6 +1042,7 @@ All Caps すべて大文字 Small Caps スモールキャップ Underline 下線+ Strikethrough 取り消し線 OpenType OpenType Kerning カーニング Ligatures 合字@@ -3297,6 +3297,7 @@ Ultra Key 울트라 키 Uncompressed (PCM) 비압축(PCM) Underline 밑줄+ Strikethrough 취소선 Undo 실행 취소 Undock Panel 패널 도킹 해제 Ungroup 그룹 해제@@ -1042,6 +1042,7 @@ All Caps Tudo em maiúsculas Small Caps Versaletes Underline Sublinhado+ Strikethrough Tachado OpenType OpenType Kerning Kerning Ligatures Ligaduras@@ -1036,6 +1036,7 @@ All Caps 全大写 Small Caps 小型大写 Underline 下划线+ Strikethrough 删除线 OpenType OpenType Kerning 字距调整 Ligatures 连字@@ -227,6 +227,7 @@ mod ime_byte_delete_tests { faux_italic: false, caps: 0, underline: false,+ strikethrough: false, box_width: 0.0, box_height: 0.0, vertical: false,@@ -116,6 +116,7 @@ const LAYOUT_PARAMS: &[&str] = &[ "faux_italic", "caps", "underline",+ "strikethrough", "box_width", "box_height", "vertical",@@ -245,6 +246,7 @@ fn layout_fingerprint(effect: &EffectInstance, t: Tick) -> u64 { hash_option(&mut h, run.style.faux_bold, |h, v| hash_bytes(h, &[u8::from(v)])); hash_option(&mut h, run.style.faux_italic, |h, v| hash_bytes(h, &[u8::from(v)])); hash_option(&mut h, run.style.underline, |h, v| hash_bytes(h, &[u8::from(v)]));+ hash_option(&mut h, run.style.strikethrough, |h, v| hash_bytes(h, &[u8::from(v)])); hash_option(&mut h, run.style.tracking, hash_f32); hash_option(&mut h, run.style.baseline_shift, hash_f32); hash_option(&mut h, run.style.caps, |h, v| hash_bytes(h, &u64::from(v).to_le_bytes()));@@ -48,6 +48,7 @@ fn char_style_key(id: &str) -> Option<&'static str> { "faux_bold" => "bold", "faux_italic" => "italic", "underline" => "underline",+ "strikethrough" => "strikethrough", "tracking" => "tracking", "baseline_shift" => "baselineShift", "caps" => "caps",@@ -593,6 +594,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ("all_caps", "TT", tl!("All Caps"), caps == 1, Tokens::ui(12.0)), ("small_caps", "Tt", tl!("Small Caps"), caps == 2, Tokens::ui(12.0)), ("underline", "U", tl!("Underline"), flag("underline"), Tokens::ui(13.0)),+ ("strikethrough", "S", tl!("Strikethrough"), flag("strikethrough"), Tokens::ui(13.0)), ] { let r = letter_button(&mut vui, label, tip, on, &t, font); if id == "faux_italic" {@@ -600,6 +602,12 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let c = r.rect.center(); vui.painter().line_segment([c + vec2(-5.0, 6.0), c + vec2(5.0, -6.0)], Stroke::new(0.8, t.text_faint)); }+ if id == "strikethrough" {+ // the stroke through the letter+ let c = r.rect.center();+ let col = if on || r.hovered() { t.text } else { t.text_dim };+ vui.painter().line_segment([c + vec2(-6.0, 0.5), c + vec2(6.0, 0.5)], Stroke::new(1.0, col));+ } cx.auto(id, r.rect, tip); if r.clicked() { match id {@@ -26,7 +26,7 @@ kerning and ligatures, bidi, line breaking, all drawn as vectors in linear light | Group | Properties (parameter ids) | |---|---|-| Text | `text` (Source Text), `font`, `font_style`, `size` (px), `align` (Left/Center/Right/Justify), `tracking` (1/1000 em), `kerning`, `ligatures`, `leading` (px added to the natural line height), `baseline_shift`, `faux_bold`, `faux_italic`, `caps` (Normal/All Caps/Small Caps), `underline`, `box_width` (0 = point text, else paragraph text wrapped at that width), `box_height` (paragraph text: lines that do not fit are not shown; 0 = as tall as the text) |+| Text | `text` (Source Text), `font`, `font_style`, `size` (px), `align` (Left/Center/Right/Justify), `tracking` (1/1000 em), `kerning`, `ligatures`, `leading` (px added to the natural line height), `baseline_shift`, `faux_bold`, `faux_italic`, `caps` (Normal/All Caps/Small Caps), `underline`, `strikethrough`, `box_width` (0 = point text, else paragraph text wrapped at that width), `box_height` (paragraph text: lines that do not fit are not shown; 0 = as tall as the text) | | Shape | `shape` (Rectangle/Ellipse/Polygon/Path), `size` [w, h], `sides`, `corner_radius` (one radius for all four corners, at most half the shorter side; on the Program monitor, drag one of the four small circles just inside a selected rectangle's corners), `points` (path vertices relative to the layer origin) | | Appearance | `fill`, `fill_color`; `stroke`, `stroke_color`, `stroke_width`, `stroke_type` (Outer/Center/Inner) and the same for `stroke2`; `background`, `background_color`, `background_opacity`, `background_size` (padding), `background_radius`; `shadow`, `shadow_color`, `shadow_opacity`, `shadow_angle` (135° = down-right), `shadow_distance`, `shadow_size`, `shadow_blur` | | Transform | `position` (graphic canvas px), `anchor` (layer px), `scale`, `scale_width`, `uniform_scale`, `rotation`, `opacity` |@@ -139,7 +139,7 @@ With a graphic clip selected, the Properties panel (and Essential Graphics) show **Align and Transform** (six align buttons, two distribute buttons, position, anchor, scale, rotation, opacity), **Text** (Source Text field, font family and style, size, paragraph alignment, tracking, leading, baseline shift, box width and height, faux bold / faux italic / all caps /-small caps / underline, kerning and ligatures; the wrench in the section's header opens **Text+small caps / underline / strikethrough, kerning and ligatures; the wrench in the section's header opens **Text Properties**: Text Layer Type and Ligatures), **Shape**, and **Appearance** (fill, two strokes, background, shadow). Automation ids: `graphics.layers.<n>`, `graphics.prop.<parameter id>`, `graphics.align.<how>`, `graphics.distribute.<axis>`, `graphics.sourceText`, `graphics.NewTextLayer`,@@ -150,7 +150,7 @@ shadow). Automation ids: `graphics.layers.<n>`, `graphics.prop.<parameter id>`, ## Per-character styles Select characters with the Type tool and change font, style, size, colour, faux bold / italic,-underline, tracking, baseline shift or caps in the Properties panel: the change applies to the+underline, strikethrough, tracking, baseline shift or caps in the Properties panel: the change applies to the selection only (`graphics.setCharStyle`). Styles are stored on the layer as runs of characters (`EffectInstance::layer.runs`, character offsets, sorted and non-overlapping) and follow the text when it is edited (typed characters take the style of the character before them). The text engine@@ -159,7 +159,7 @@ lines grow to fit the largest run, and each run can have its own fill colour. | Command | Params | |---|---|-| `graphics.setCharStyle` | `clip`, `layer`, `start`, `end` (characters; default the whole text), `style` {`font`, `fontStyle`, `size`, `color`, `bold`, `italic`, `underline`, `tracking`, `baselineShift`, `caps`}, `clear` (remove styles from the range) |+| `graphics.setCharStyle` | `clip`, `layer`, `start`, `end` (characters; default the whole text), `style` {`font`, `fontStyle`, `size`, `color`, `bold`, `italic`, `underline`, `strikethrough`, `tracking`, `baselineShift`, `caps`}, `clear` (remove styles from the range) | ## Responsive design