From 9c1d65d9241be7c9069a3d0c1a4b0a0d1756a219 Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:36:39 -0500 Subject: [PATCH] fix(render): text strokes follow their alignment (#615) The canvas renderer and PDF export stroked text with the alignment forced to centre, so Inside and Outside on text did nothing (SVG export already honoured them). Glyph outlines are closed, so text strokes now go through stroke_area like other shapes' strokes, with the same fill rule: inside clips to the glyphs, outside to around them. --- crates/pdf/src/draw.rs | 2 +- crates/pdf/src/text.rs | 10 +++---- crates/render/src/draw.rs | 6 ++-- crates/render/src/paint_behaviour_tests.rs | 32 ++++++++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 99f4e390..1ff48409 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -116,7 +116,7 @@ pub(crate) fn rule(r: figcraft_render::FillRule) -> FillRule { } /// The renderer's stroke fill rule: even-odd ring paths for aligned strokes, else non-zero. -fn stroke_rule(n: &Node, closed: bool) -> FillRule { +pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule { if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) { FillRule::EvenOdd } else { diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index c425404d..cb2ec7a2 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use std::ops::Range; use std::sync::Arc; -use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeProps}; +use figcraft_doc::{Node, NodeKind}; use figcraft_geom::{Affine, BezPath}; use figcraft_text::fontdb::FontBytes; use figcraft_text::{Face, Glyph, TextLayout}; @@ -16,7 +16,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph}; use figcraft_color::{GradientKind, Paint, PaintKind}; -use crate::draw::{Exporter, above, affine_ok, blend, to_xf, vector_paint}; +use crate::draw::{Exporter, above, affine_ok, blend, stroke_rule, to_xf, vector_paint}; use crate::raster::Raster; /// Glyphs of one face and size on one line, written as one text run. @@ -84,11 +84,11 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine } } ex.place_inner(s, inner); - let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() }; + // Glyph outlines are closed: inside and outside strokes clip to them, as on the canvas (#615). for outlines in &stroked { - let area = figcraft_pathops::stroke_center(outlines, props.weight, &props); + let area = figcraft_render::draw::stroke_area(n, outlines, true); for p in &n.strokes { - ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf); + ex.fill(s, &area, stroke_rule(n, true), p, w, h, gxf); } } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..334589aa 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -621,11 +621,11 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, f(pix); } if n.has_visible_stroke() { - let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() }; + // Glyph outlines are closed: inside and outside strokes clip to them (#615). for path in keys.iter().filter_map(|k| by_style.get(k)) { - let s = figcraft_pathops::stroke_center(path, props.weight, &props); + let s = stroke_area(n, path, true); if let Some(ts) = ts_path(&s) { - fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, ctx, None); + fill_with(pix, &ts, &n.strokes, w, h, gxf, stroke_rule(n, true), ctx, None); } } } diff --git a/crates/render/src/paint_behaviour_tests.rs b/crates/render/src/paint_behaviour_tests.rs index f4bad2ca..30c583d0 100644 --- a/crates/render/src/paint_behaviour_tests.rs +++ b/crates/render/src/paint_behaviour_tests.rs @@ -630,3 +630,35 @@ fn changing_the_colour_profile_converts_or_keeps_values() { let seen = draw(&kept); assert!(seen[0] > before[0] && seen[2] < before[2], "kept values look more saturated: {seen:?} vs {before:?}"); } + +// ---- strokes on text ---- + +#[test] +fn text_strokes_follow_their_alignment() { + // #615: as on other shapes (and in SVG export), an inside stroke stays in the glyphs, a centre + // one reaches half its weight past them and an outside one all of it. + figcraft_text::scan_system_fonts(); + let left_edge = |align: StrokeAlign| -> Option { + let (mut d, p) = page(); + let mut td = figcraft_doc::TextData::new("I"); + td.style.font_size = 120.0; + let t = d.create(p, NodeKind::Text(td), "T", 60.0, 30.0, 1.0, 1.0).unwrap(); + let n = d.get_mut(t).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(hex("#000000"))]; + n.stroke.weight = 12.0; + n.stroke.align = align; + let (w, h) = figcraft_text::auto_size(n)?; + (n.width, n.height) = (w, h); + let pix = render(&d); + (0..200).find(|x| (0..200).any(|y| px(&pix, *x, y)[0] < 128)) + }; + let (Some(inside), Some(centre), Some(outside)) = + (left_edge(StrokeAlign::Inside), left_edge(StrokeAlign::Center), left_edge(StrokeAlign::Outside)) + else { + eprintln!("no font to draw text with; skipping"); + return; + }; + assert!((5..=7).contains(&(inside - centre)), "centre reaches half the weight past inside: {inside} → {centre}"); + assert!((5..=7).contains(&(centre - outside)), "outside reaches the rest: {centre} → {outside}"); +}