From 270dd46b5062c8295a9103fedbe934ac83f99f27 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 21:06:42 +0000 Subject: [PATCH] Text: stroke behind fill (#619) A character attribute strokeBehindFill paints a text stroke under the fill at double width, so its full weight lies outside the letters and they keep their shape. Screen and PNG rendering, PDF export (stroke as outlines first, then the filled text, so the text is not duplicated; clipped to the outside of the glyphs when there is no fill), Create Outlines (outside- aligned stroke), the Properties panel and Character Style Options. IDML StrokeAlignment on CharacterStyleRange, CharacterStyle and ParagraphStyle imports and exports as the attribute (OutsideAlignment = on, CenterAlignment = off). --- ROADMAP.md | 3 +- crates/compose/src/lib.rs | 2 + crates/compose/src/shape.rs | 1 + crates/doc/src/attrs.rs | 17 ++++ crates/engine/src/cmd/paths.rs | 10 ++- crates/idml/src/export.rs | 3 + crates/idml/src/import.rs | 6 ++ crates/idml/src/tests.rs | 19 +++++ crates/pdf/src/tests.rs | 61 ++++++++++++++ crates/pdf/src/text.rs | 99 +++++++++++++++++------ crates/render/src/text.rs | 96 +++++++++++++--------- crates/render/tests/text_stroke_behind.rs | 63 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/panels/properties.rs | 4 + docs/typography-parity.md | 3 +- 15 files changed, 324 insertions(+), 66 deletions(-) create mode 100644 crates/render/tests/text_stroke_behind.rs diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..6106d978f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..2c3e7ccc0 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -50,6 +50,8 @@ pub struct RunStyle { pub stroke: String, pub stroke_tint: f32, pub stroke_weight: f64, + /// The stroke goes under the fill at double width, so its full weight shows outside the glyphs. + pub stroke_behind_fill: bool, pub underline: bool, pub strikethrough: bool, /// The underline and strikethrough bars (Underline / Strikethrough Options resolved). diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..a781a467d 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -138,6 +138,7 @@ impl StyleTable<'_> { stroke: p.stroke.clone(), stroke_tint: p.stroke_tint, stroke_weight: p.stroke_weight, + stroke_behind_fill: p.stroke_behind_fill, underline: p.underline, strikethrough: p.strikethrough, underline_rule: crate::Rule { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..6a0fe4d1f 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -735,6 +735,9 @@ attr_set! { stroke: String = "[None]".into(), stroke_tint: f32 = 1.0, stroke_weight: f64 = 1.0, + /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the + /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. + stroke_behind_fill: bool = false, capitalization: Capitalization = Capitalization::Normal, position: Position = Position::Normal, underline: bool = false, @@ -961,6 +964,20 @@ mod tests { assert_eq!(a.rule_below, None, "null clears the override"); } + #[test] + fn stroke_behind_fill_is_saved_only_when_set() { + assert!(!CharProps::default().stroke_behind_fill); + assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); + let mut a = CharAttrs::default(); + a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + let mut p = CharProps::default(); + p.apply(&a); + assert!(p.stroke_behind_fill); + } + #[test] fn digits_map_to_their_script() { assert_eq!(Digits::Default.map('7', "Arabic"), '7'); diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..53eb4aff0 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -246,7 +246,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { let cs = designcraft_compose::compose_story(d, tf.story, &Default::default()); let Some(ft) = cs.frame(*id) else { continue }; // One path per text look (fill, stroke). - let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new(); + let mut runs: Vec<((String, f32, String, f32, f64, bool), BezPath)> = Vec::new(); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| g.visible) { let st = &cs.styles[g.style as usize]; @@ -256,7 +256,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { } let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); + let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight, st.stroke_behind_fill); let k = match runs.iter().position(|r| r.0 == key) { Some(k) => k, None => { @@ -274,14 +274,16 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); let text_xf = d.text_xf(&it); - let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { + let make = |d: &mut Document, ((fill, ft, stroke, stt, sw, behind), bp): ((String, f32, String, f32, f64, bool), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; o.stroke = if stroke == designcraft_color::swatch::NONE { Stroke::none() } else { - Stroke { swatch: stroke, tint: stt, weight: sw, ..Stroke::default() } + // A stroke behind the fill lies wholly outside the letters, as an outside-aligned stroke. + let align = if behind { designcraft_doc::StrokeAlign::Outside } else { designcraft_doc::StrokeAlign::Center }; + Stroke { swatch: stroke, tint: stt, weight: sw, align, ..Stroke::default() } }; o }; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..0800fa2b2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1117,6 +1117,9 @@ impl<'a> Ex<'a> { if let Some(v) = a.stroke_weight { el.set("StrokeWeight", num(v)); } + if let Some(v) = a.stroke_behind_fill { + el.set("StrokeAlignment", if v { "OutsideAlignment" } else { "CenterAlignment" }); + } if let Some(v) = a.capitalization { el.set("Capitalization", names::caps_out(v)); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..4b8cbf45d 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1172,6 +1172,12 @@ impl<'r> Importer<'r> { } a.stroke_tint = tint(e.num("StrokeTint")); a.stroke_weight = e.num("StrokeWeight"); + // Text strokes are centred on the outline or lie outside it, behind the fill. + a.stroke_behind_fill = e.prop("StrokeAlignment").and_then(|v| match v.trim() { + "OutsideAlignment" => Some(true), + "CenterAlignment" => Some(false), + _ => None, + }); a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim())); a.position = e.prop("Position").and_then(|v| names::position_in(v.trim())); a.underline = e.boolean("Underline"); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..e4c6e9f92 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -842,6 +842,25 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() { assert_eq!(a.leading_model, b.leading_model); } +#[test] +fn text_stroke_alignment_imports_as_stroke_behind_fill_and_round_trips() { + let story = r#" + + Behind + Over + Plain + "#; + let flags = |d: &Document| -> Vec<(String, Option)> { + let st = d.stories.values().find(|s| s.text.contains("Behind")).unwrap(); + st.runs().map(|(r, f)| (st.text[r].to_string(), f.over.stroke_behind_fill)).filter(|(t, _)| !t.trim().is_empty()).collect() + }; + let want = vec![("Behind".to_string(), Some(true)), ("Over".to_string(), Some(false)), ("Plain".to_string(), None)]; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + assert_eq!(flags(&doc), want); + let back = import_idml(&export_idml(&doc)).unwrap(); + assert_eq!(flags(&back), want); +} + #[test] fn warichu_imports_from_idml_and_round_trips() { let story = r#" diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..234e72a36 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -402,6 +402,67 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { (rec.0, rec.1) } +/// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width. +fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + #[derive(Default)] + struct Rec(Vec<(String, f32)>); + impl Device<'_> for Rec { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, m: &PathDrawMode) { + self.0.push(match m { + PathDrawMode::Fill(_) => ("fill path".into(), 0.0), + PathDrawMode::Stroke(p) => ("stroke path".into(), p.line_width), + }); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) { + self.0.push(match m { + GlyphDrawMode::Fill => ("fill text".into(), 0.0), + GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width), + GlyphDrawMode::Invisible => ("invisible text".into(), 0.0), + }); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let page = &pdf.pages()[0]; + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + let mut rec = Rec::default(); + interpret_page(page, &mut ctx, &mut rec); + rec.0 +} + +#[test] +fn stroke_behind_fill_strokes_the_run_before_filling_it() { + let export = |behind: bool| { + let mut d = doc_with_text("I"); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.stroke = Some(designcraft_color::swatch::BLACK.into()); + f.over.stroke_weight = Some(4.0); + f.over.stroke_behind_fill = Some(behind); + }); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let ops: Vec<(String, f32)> = paint_order(&bytes).into_iter().filter(|(op, w)| op.ends_with("text") || *w > 0.0).collect(); + (ops, extract_text(&bytes)[0].clone()) + }; + let (over, _) = export(false); + assert_eq!(over, [("fill text".to_string(), 0.0), ("stroke text".to_string(), 4.0)]); + let (behind, text) = export(true); + assert_eq!(behind, [("stroke path".to_string(), 8.0), ("fill text".to_string(), 0.0)], "the double-width stroke goes first"); + assert_eq!(text.trim(), "I", "the text is not duplicated"); +} + #[test] fn missing_glyphs_print_as_boxes() { // Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..0cd4770a9 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -300,16 +300,53 @@ impl Exporter<'_> { let hs = g0.sx / g0.sy; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); + // Stroke behind fill: a double-width stroke first, as outlines so the text is not + // duplicated, then the fill over its inner half. With no fill, the stroke is clipped + // to the outside of the glyphs instead. + let behind = st.stroke_behind_fill && stroke.is_some(); + let weight = if behind { st.stroke_weight * 2.0 } else { st.stroke_weight }; + let mut clipped = false; + let stroke = if behind { + let outlines = glyph_outlines(glyphs, size); + if fill.is_some() { + self.paint_outlines(s, &outlines, origin, None, stroke, weight); + None + } else { + clipped = self.push_outside_clip(s, &outlines, origin, weight); + stroke + } + } else { + stroke + }; // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); - return; - } - let Some(font) = self.font(&face) else { + self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight); + } else if let Some(font) = self.font(&face) { + self.text_run(s, glyphs, story, font, origin, size, hs, fill, stroke, weight); + } else { self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style)); - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); - return; - }; + self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight); + } + if clipped { + s.pop(); + } + } + + /// A run as real text. + #[allow(clippy::too_many_arguments)] + fn text_run( + &mut self, + s: &mut Surface, + glyphs: &[PlacedGlyph], + story: &str, + font: Font, + origin: Affine, + size: f64, + hs: f64, + fill: Option, + stroke: Option, + stroke_weight: f64, + ) { let (text, ranges) = self.run_text(glyphs, story); let k = 1.0 / (hs * size); let kg: Vec = glyphs @@ -323,36 +360,24 @@ impl Exporter<'_> { .collect(); s.push_transform(&tf(origin)); s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() })); + s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); s.set_fill(None); s.set_stroke(None); s.pop(); } - /// Fallback for faces krilla can't embed: glyph outlines as paths. - #[allow(clippy::too_many_arguments)] - fn outline_run( + /// Glyph outlines as paths (faces krilla can't embed, missing glyphs, strokes behind fill). + fn paint_outlines( &mut self, s: &mut Surface, - glyphs: &[PlacedGlyph], + outlines: &BezPath, origin: Affine, - size: f64, fill: Option, stroke: Option, stroke_weight: f64, ) { - let db = FontDb::global(); - let g0 = &glyphs[0]; - let k = size / g0.face.upem; - let mut bp = BezPath::new(); - for g in glyphs { - let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); - let mut o = (*db.outline(&g.face, g.gid)).clone(); - o.apply_affine(a); - bp.extend(o.iter()); - } - let Some(p) = to_path(&bp) else { return }; + let Some(p) = to_path(outlines) else { return }; s.push_transform(&tf(origin)); s.set_fill(fill.map(|c| solid_fill(c, 1.0))); s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); @@ -362,6 +387,17 @@ impl Exporter<'_> { s.pop(); } + /// Clip to everything outside the glyphs (a frame around them plus their outlines, + /// even-odd). False when there was nothing to clip with. + fn push_outside_clip(&mut self, s: &mut Surface, outlines: &BezPath, origin: Affine, weight: f64) -> bool { + let m = weight * 2.0 + 10.0; + let mut outside = outlines.bounding_box().inflate(m, m).to_path(0.1); + outside.extend(outlines.iter()); + let Some(p) = to_path(&(origin * outside)) else { return false }; + s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); + true + } + /// One line of plain text in the bundled UI face (page information mark). pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) { let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular"); @@ -391,3 +427,18 @@ impl Exporter<'_> { s.set_fill(None); } } + +/// A run's glyph outlines at `size`, relative to its first glyph's origin. +fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64) -> BezPath { + let db = FontDb::global(); + let mut bp = BezPath::new(); + let Some(g0) = glyphs.first() else { return bp }; + let k = size / g0.face.upem; + for g in glyphs { + let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); + let mut o = (*db.outline(&g.face, g.gid)).clone(); + o.apply_affine(a); + bp.extend(o.iter()); + } + bp +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..57103a234 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -358,32 +358,9 @@ impl Renderer { let lg = &fg.lines[i]; self.stats.glyphs += lg.glyphs; for (si, bp) in &lg.runs { - if let Some(c) = fill_of(*si) { - let st = &cs.styles[*si as usize]; - let op = crate::overprints(f, &st.fill, st.fill_tint, false); - if op { - ctx.push_layer( - None, - Some(vello_cpu::peniko::BlendMode::new(vello_cpu::peniko::Mix::Multiply, vello_cpu::peniko::Compose::SrcOver)), - None, - None, - None, - ); - } - ctx.set_paint(c); - ctx.fill_path(bp); - if op { - ctx.pop_layer(); - } - } let st = &cs.styles[*si as usize]; - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(bp); - } + let fill = fill_of(*si).map(|c| (c, crate::overprints(f, &st.fill, st.fill_tint, false))); + paint_glyphs(ctx, bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill); } } for &i in &shown { @@ -456,6 +433,62 @@ impl Renderer { } /// Kurbo stroke for a table edge. +/// A run's stroke colour, if it has one. +fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle) -> Option { + if st.stroke == designcraft_color::swatch::NONE { + return None; + } + doc.resolve_color(&st.stroke, st.stroke_tint).map(|c| color_of(&c, 1.0)) +} + +/// Paint glyph outlines: the fill (multiplied when it overprints), then the stroke over it. With +/// stroke behind fill a double-width stroke goes first and the fill covers its inner half; with +/// no fill to cover it, the glyph shapes are cut out of the stroke instead. +fn paint_glyphs( + ctx: &mut RenderContext, + bp: &BezPath, + fill: Option<(peniko::Color, bool)>, + stroke: Option, + weight: f64, + behind: bool, +) { + let paint_fill = |ctx: &mut RenderContext| { + let Some((c, overprint)) = fill else { return }; + if overprint { + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Multiply, peniko::Compose::SrcOver)), None, None, None); + } + ctx.set_paint(c); + ctx.fill_path(bp); + if overprint { + ctx.pop_layer(); + } + }; + let Some(sc) = stroke else { + paint_fill(ctx); + return; + }; + if !behind { + paint_fill(ctx); + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight)); + ctx.stroke_path(bp); + return; + } + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight * 2.0)); + if fill.is_some() { + ctx.stroke_path(bp); + paint_fill(ctx); + } else { + ctx.push_layer(None, None, None, None, None); + ctx.stroke_path(bp); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None); + ctx.fill_path(bp); + ctx.pop_layer(); + ctx.pop_layer(); + } +} + fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke { let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square); match &s.kind { @@ -484,17 +517,8 @@ impl Renderer { ctx.set_transform(f.view * xf); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_path(&bp); - } - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(&bp); - } + let fill = doc.resolve_color(&st.fill, st.fill_tint).map(|c| (color_of(&c, 1.0), false)); + paint_glyphs(ctx, &bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill); self.stats.glyphs += 1; } } diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/text_stroke_behind.rs new file mode 100644 index 000000000..103bee563 --- /dev/null +++ b/crates/render/tests/text_stroke_behind.rs @@ -0,0 +1,63 @@ +//! Stroke behind fill on text: the fill covers the inner half of a double-width stroke. +use designcraft_color::{Color, swatch::Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, ParaFormat, SpreadRef}; +use designcraft_geom::Rect; +use designcraft_render::{RenderOptions, Rendered, Renderer}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const WEIGHT: f64 = 12.0; + +/// A 200 pt "I" filled with `fill`, stroked red at [`WEIGHT`] (none when `stroke` is false). +fn render(fill: &str, stroke: bool, behind: bool) -> Rendered { + let mut d = Document::new(&NewDocument { width: 400.0, height: 400.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Red", Color::rgb8(255, 0, 0))); + d.swatches.push(Swatch::color("Blue", Color::rgb8(0, 0, 255))); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 0.0, 400.0, 400.0), lid, "I", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.size = Some(200.0); + f.over.fill = Some(fill.into()); + f.over.stroke = Some(if stroke { "Red" } else { designcraft_color::swatch::NONE }.into()); + f.over.stroke_weight = Some(WEIGHT); + f.over.stroke_behind_fill = Some(behind); + }); + let mut r = Renderer::new(); + r.threads = 0; + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + assert!(!cs.frames[0].lines.is_empty(), "the glyph is set"); + r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { paper: false, background: Some(WHITE), ..Default::default() }).unwrap() +} + +/// The left edge of the stem on a row through the middle of the unstroked glyph. +fn stem_edge() -> (u32, u32) { + let img = render("Blue", false, false); + let blue: Vec<(u32, u32)> = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) == BLUE).collect(); + assert!(!blue.is_empty(), "the glyph is drawn"); + let y0 = blue.iter().map(|p| p.1).min().unwrap(); + let y1 = blue.iter().map(|p| p.1).max().unwrap(); + let y = (y0 + y1) / 2; + let x = (0..img.width).find(|&x| img.pixel(x, y) == BLUE).unwrap(); + (x, y) +} + +#[test] +fn stroke_behind_fill_shows_only_outside_the_letter() { + let (x, y) = stem_edge(); + let inner = x + 3; // inside the outline, within the inner half of a centred stroke + let outer = x - 9; // outside the outline, beyond the outer half of a centred stroke + let over = render("Blue", true, false); + assert_eq!(over.pixel(inner, y), RED, "a centred stroke covers the fill's edge"); + assert_eq!(over.pixel(outer, y), WHITE, "a centred stroke reaches half its weight outside"); + let behind = render("Blue", true, true); + assert_eq!(behind.pixel(inner, y), BLUE, "the fill covers the stroke behind it"); + assert_eq!(behind.pixel(outer, y), RED, "the full stroke weight lies outside the letter"); + assert_eq!(behind.pixel(x - 15, y), WHITE, "and no further"); + let hollow = render(designcraft_color::swatch::NONE, true, true); + assert_eq!(hollow.pixel(inner, y), WHITE, "with no fill the letter stays empty"); + assert_eq!(hollow.pixel(outer, y), RED); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..cee849a99 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3060,6 +3060,9 @@ fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharField crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0)); ui.end_row(); + ui.label(""); + char_check(ui, d, cf, "strokeBehindFill", crate::i18n::tr(lang, "Stroke Behind Fill")); + ui.end_row(); }); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..bc00d37b2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -973,6 +973,10 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { } ui.add_space(4.0); } + let mut behind = c["strokeBehindFill"].as_bool().unwrap_or(false); + if ui.checkbox(&mut behind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stroke Behind Fill"))).changed() { + let _ = app.run("type.char", json!({"attrs": {"strokeBehindFill": behind}})); + } } /// Character section: font family (with search), style, size/leading, kerning/tracking, •••. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..093058395 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -40,6 +40,7 @@ Fidelity: **unmeasured** unless stated. | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | +| Character fill and stroke | fill, stroke, tint, weight, stroke aligned to centre or outside | fill, stroke, tints, weight, stroke behind fill (screen, PNG, PDF, IDML `StrokeAlignment`) | ~ | text strokes have no join or miter limit settings (#619 for stroke behind fill) | | OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | | | Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | | | Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md |