diff --git a/ROADMAP.md b/ROADMAP.md index ccda3d1eb..b9fe40292 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5–7 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-11 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; 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. @@ -128,6 +128,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** · Type tool: Option/Alt+Left/Right kerns the pair at the caret (manual kerning) or tracks a selection by the Kerning/Tracking increment (Preferences › Units & Increments, 20/1000 em), five times with Cmd/Ctrl; moving by words is Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere, and Option/Alt+Shift+Left/Right still selects by words (#621). No readiness percentage change. - **2026-10-12** · Autoflow on Place threads one frame per page column (right to left in a right-to-left story), so text no longer runs across the gutter; pages Smart Text Reflow adds get a primary frame with the page's columns (#625). No readiness percentage change. - **2026-10-12** · Vector art copied in Illustrator, VectorCraft, Figma or Inkscape pastes as editable paths, as InDesign pastes Illustrator art: the SVG those apps put on the clipboard becomes paths with their fills, strokes (weight, caps, joins, dashes) and opacity, groups stay groups and text comes in as outlines, centred in the view (`edit.pasteSvg`, `ui.paste`). Edit › Paste reads the system clipboard when nothing was copied in DesignCraft. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index fa401aa65..3b09100b3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -59,6 +59,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 ef06cbb97..f608c139f 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -260,6 +260,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 65a477f45..8c5ff1938 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -973,6 +973,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, @@ -1429,6 +1432,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 a512fdaab..3541bd231 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]; @@ -257,7 +257,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 shatai = st.shatai_xf(g, l.baseline).unwrap_or(Affine::IDENTITY); let a = shatai * 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 => { @@ -275,14 +275,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 234de2890..d11a2ede8 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1141,6 +1141,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 55bcd967b..20fe98074 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1402,6 +1402,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 592ce6764..06990f531 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1447,6 +1447,25 @@ fn composers_import_by_kind_and_round_trip() { } } +#[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 d27d03d31..35c1ab81b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -415,6 +415,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 fb0f17ea2..aaf00e995 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -443,16 +443,55 @@ impl Exporter<'_> { let tan = if st.skew != 0.0 { st.skew.to_radians().tan() } else { 0.0 }; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -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, tan); + 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, tan, size, fill, stroke, st.stroke_weight); - return; - } - let Some(font) = self.font(&face) else { + self.paint_outlines(s, &glyph_outlines(glyphs, size, tan), origin, fill, stroke, weight); + } else if let Some(font) = self.font(&face) { + self.text_run(s, glyphs, story, font, origin, size, hs, tan, 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, tan, size, fill, stroke, st.stroke_weight); - return; - }; + self.paint_outlines(s, &glyph_outlines(glyphs, size, tan), 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, + tan: f64, + fill: Option, + stroke: Option, + stroke_weight: f64, + ) { + let Some(g0) = glyphs.first() else { return }; let (text, mut ranges) = self.run_text(glyphs, story); let order = split_clusters(glyphs, &text, &mut ranges); let k = 1.0 / (hs * size); @@ -472,38 +511,24 @@ impl Exporter<'_> { let start = order.first().and_then(|&i| glyphs.get(i)).map_or(0.0, |g| (g.x - g0.x) / hs); 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(start as f32, 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, - tan: f64, - 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 dy = g.y - g0.y; - let a = Affine::translate(((g.x - g0.x + tan * dy) / (g0.sx / g0.sy), dy)) * 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() })); @@ -513,6 +538,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"); @@ -542,3 +578,20 @@ impl Exporter<'_> { s.set_fill(None); } } + +/// A run's glyph outlines at `size`, relative to its first glyph's origin. A vertical offset (a +/// mark above or below its base) is undone by the skew `tan` so it lands where it was composed. +fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: 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 dy = g.y - g0.y; + let a = Affine::translate(((g.x - g0.x + tan * dy) / (g0.sx / g0.sy), dy)) * 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 33f938be5..8dbc115b6 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -382,45 +382,10 @@ 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, st.overprint_fill); - 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) - { - let op = crate::overprints(f, &st.stroke, st.stroke_tint, st.overprint_stroke); - 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(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(bp); - if op { - ctx.pop_layer(); - } - } + let fill = fill_of(*si).map(|c| (c, crate::overprints(f, &st.fill, st.fill_tint, st.overprint_fill))); + let stroke = stroke_of(doc, st).map(|c| (c, crate::overprints(f, &st.stroke, st.stroke_tint, st.overprint_stroke))); + paint_glyphs(ctx, bp, fill, stroke, st.stroke_weight, st.stroke_behind_fill); } } for &i in &shown { @@ -504,6 +469,78 @@ impl Renderer { } } +/// 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)) +} + +/// Run `paint` in a Multiply layer when `overprint` is set. +fn overprinting(ctx: &mut RenderContext, overprint: bool, paint: impl FnOnce(&mut RenderContext)) { + if overprint { + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Multiply, peniko::Compose::SrcOver)), None, None, None); + } + paint(ctx); + if overprint { + ctx.pop_layer(); + } +} + +/// Paint glyph outlines: the fill, then the stroke over it, each multiplied when it overprints +/// (`(colour, overprint)`). 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<(peniko::Color, bool)>, + weight: f64, + behind: bool, +) { + let paint_fill = |ctx: &mut RenderContext| { + let Some((c, overprint)) = fill else { return }; + overprinting(ctx, overprint, |ctx| { + ctx.set_paint(c); + ctx.fill_path(bp); + }); + }; + let Some((sc, stroke_overprint)) = stroke else { + paint_fill(ctx); + return; + }; + if !behind { + paint_fill(ctx); + overprinting(ctx, stroke_overprint, |ctx| { + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight)); + ctx.stroke_path(bp); + }); + return; + } + if fill.is_some() { + overprinting(ctx, stroke_overprint, |ctx| { + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight * 2.0)); + ctx.stroke_path(bp); + }); + paint_fill(ctx); + } else { + overprinting(ctx, stroke_overprint, |ctx| { + ctx.push_layer(None, None, None, None, None); + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight * 2.0)); + 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(); + }); + } +} + /// Kurbo stroke for a table edge `length` points long of a resolved stroke type. pub(crate) fn cell_stroke(kind: &designcraft_doc::StrokeType, weight: f64, length: f64) -> kurbo::Stroke { use designcraft_doc::StrokeType; @@ -536,17 +573,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).map(|c| (c, false)), 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 10a7eb8fa..fe0c0f6c2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4546,6 +4546,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 956197d40..ee3673a03 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1112,6 +1112,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 41d278a76..6828f7946 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-12 · **Change:** minor (Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343)) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); 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, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | 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 |