Export text strokes to WASM SVG (#11578)

SkSVGDevice drops save_layer composition for text strokes, so emit
center strokes (with <g opacity> when alpha), inner strokes via glyph
clipPath, and outer strokes via inverse-glyph luminance mask.

Closes #11386
This commit is contained in:
Alejandro Alonso 2026-09-16 15:54:07 +02:00 committed by GitHub
parent 76e9289fcf
commit b8af89c1b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 846 additions and 18 deletions

View File

@ -241,6 +241,21 @@ impl SvgLayerCanvas {
));
id
}
/// Finalizes a fragment canvas as a luminance `<mask>` def (white shows,
/// black hides). Used for outer text strokes: white canvas minus black
/// glyphs keeps only the exterior half of a double-width stroke.
pub(super) fn finish_mask_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix));
self.defs.push_str(&format!(
"<mask id=\"{id}\" maskUnits=\"userSpaceOnUse\">{geometry}</mask>"
));
}
}
fn color_matrix_values(color: skia::Color) -> String {

View File

@ -1,5 +1,7 @@
//! GPU-free scene builders and render helpers for SVG export tests.
use std::sync::{Mutex, OnceLock};
use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
@ -242,6 +244,52 @@ pub(super) fn add_solid_text(
);
}
/// Solid text with an optional solid stroke (`kind`, width, color).
pub(super) fn add_text_with_stroke(
pool: &mut ShapesPool,
id: Uuid,
bounds: (f32, f32, f32, f32),
text: &str,
font_size: f32,
fill: skia::Color,
stroke: Option<(StrokeKind, f32, skia::Color)>,
) {
add_text_with_fills(
pool,
id,
bounds,
text,
font_size,
vec![Fill::Solid(SolidColor(fill))],
);
if let Some((kind, width, color)) = stroke {
let shape = pool.get_mut(&id).expect("text shape");
shape.add_stroke(solid_stroke(kind, width, color));
}
}
/// Solid-filled text with a single image-filled stroke.
pub(super) fn add_text_with_image_stroke(
pool: &mut ShapesPool,
id: Uuid,
bounds: (f32, f32, f32, f32),
text: &str,
font_size: f32,
fill: skia::Color,
stroke: Stroke,
) {
add_text_with_fills(
pool,
id,
bounds,
text,
font_size,
vec![Fill::Solid(SolidColor(fill))],
);
let shape = pool.get_mut(&id).expect("text shape");
shape.add_stroke(stroke);
}
/// Adds a single-line text shape with the given fill stack (top → bottom).
pub(super) fn add_text_with_fills(
pool: &mut ShapesPool,
@ -419,6 +467,13 @@ pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
render_with(pool, root, |_resources| {})
}
/// SVG export installs a process-wide resources pointer; serialize tests that
/// call this so parallel rustc threads do not race / SIGSEGV.
fn svg_export_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
/// before export.
pub(super) fn render_with(
@ -426,6 +481,9 @@ pub(super) fn render_with(
root: Uuid,
setup: impl FnOnce(&mut RenderResources),
) -> String {
let _serial = svg_export_test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts);
setup(&mut resources);

View File

@ -76,11 +76,11 @@ fn emit_image_fill(
let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
emit_linked_image_element(
builder,
shape,
image_fill,
dest_rect,
&href,
&clip_id,
Some(&clip_id),
false,
draw_matrix,
);
Ok(())
@ -158,11 +158,11 @@ fn emit_image_stroke(
let dest = image_stroke_dest_rect(shape, stroke);
emit_linked_image_element(
builder,
shape,
image_fill,
dest,
&href,
&clip_id,
Some(&clip_id),
false,
draw_matrix,
);
Ok(())
@ -187,21 +187,27 @@ fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
dest
}
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
/// Emits `<image href>` at `dest_rect`, under the page CTM.
///
/// When `clip_id` is set, wraps the image in `<g clip-path>`. Pass `None` when
/// the caller already confines the image (e.g. a luminance stroke mask).
///
/// When `force_cover` is true, uses `xMidYMid slice` regardless of
/// `keep_aspect_ratio` — matching GPU text stroke image shaders.
///
/// `draw_matrix` is the shape-local CTM (must include container drop silhouette
/// offset when drawing under a parent shadow filter).
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
_shape: &Shape,
image_fill: &ImageFill,
dest_rect: MathRect,
href: &str,
clip_id: &str,
clip_id: Option<&str>,
force_cover: bool,
draw_matrix: skia_safe::Matrix,
) {
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
let preserve = if force_cover || image_fill.keep_aspect_ratio() {
"xMidYMid slice"
} else {
"none"
@ -214,7 +220,9 @@ pub(super) fn emit_linked_image_element(
format!(r#" opacity="{opacity}""#)
};
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
if let Some(id) = clip_id {
builder.open_group(&format!("clip-path=\"url(#{id})\""));
}
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
dest_rect.left(),
@ -222,7 +230,9 @@ pub(super) fn emit_linked_image_element(
dest_rect.width(),
dest_rect.height(),
));
builder.close_group();
if clip_id.is_some() {
builder.close_group();
}
}
pub(super) fn xml_escape_attr(s: &str) -> String {

View File

@ -61,9 +61,11 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// `<clipPath>`.
///
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// wrapper. Masks and text strokes still need dedicated SVG re-emission.
/// wrapper. Masks still need dedicated SVG re-emission.
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
/// image-filled strokes use a linked `<image>` clipped to the stroke.
/// image-filled strokes use a linked `<image>` clipped to the stroke;
/// text strokes use `<g opacity>` / glyph clipPath / inverse glyph mask, and
/// image-filled text strokes use a linked `<image>` under a stroke luminance mask.
pub fn render_to_svg(
shared: &mut RenderResources,
id: &Uuid,
@ -133,7 +135,7 @@ mod text;
use document::SvgLayerCanvas;
use frames::render_frame;
use groups::render_group;
use text::render_text_fill;
use text::{render_text_fill, render_text_strokes};
use document::{effect_attrs, push_text_silhouette_spread_filter, shape_with_selrect_outset};
use images::{emit_fills, emit_strokes};
@ -208,6 +210,7 @@ fn render_leaf(
builder.open_group(&format!("filter=\"url(#{id})\""));
}
render_text_fill(builder, shared, element, draw_matrix)?;
render_text_strokes(builder, shared, element, draw_matrix)?;
if morph_id.is_some() {
builder.close_group();
}

View File

@ -0,0 +1,17 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="101" height="106" viewBox="0 0 101 106"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="txtstrokemask0" maskUnits="userSpaceOnUse">
<rect transform="translate(-155 -594)" x="-100000" y="-100000" width="200000" height="200000"/>
<defs/>
<text fill="none" stroke="white" stroke-width="20" stroke-miterlimit="4" transform="translate(-155 -594)" font-size="72" font-family="Source Sans Pro" x="165, 204.16406" y="669">
Aa
</text>
</mask><mask id="tmask1" maskUnits="userSpaceOnUse">
<rect fill="white" transform="translate(-155 -594)" x="-100000" y="-100000" width="200000" height="200000"/>
<text transform="translate(-155 -594)" font-size="72" font-family="Source Sans Pro" x="165, 204.16406" y="669">
Aa
</text>
</mask></defs><g mask="url(#tmask1)"><g mask="url(#txtstrokemask0)"><image href="images/test-fill.svg" x="119.5" y="604" width="172" height="86" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -155 -594)"/></g></g></svg>

View File

@ -0,0 +1,21 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="101" height="106" viewBox="0 0 101 106"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="txtstrokemask0" maskUnits="userSpaceOnUse">
<rect transform="translate(-155 -594)" x="-100000" y="-100000" width="200000" height="200000"/>
<defs/>
<text fill="none" stroke="white" stroke-width="20" stroke-miterlimit="4" transform="translate(-155 -594)" font-size="72" font-family="Source Sans Pro" x="165, 204.16406" y="669">
Aa
</text>
</mask><mask id="tmask1" maskUnits="userSpaceOnUse">
<rect fill="white" transform="translate(-155 -594)" x="-100000" y="-100000" width="200000" height="200000"/>
<text transform="translate(-155 -594)" font-size="72" font-family="Source Sans Pro" x="165, 204.16406" y="669">
Aa
</text>
</mask></defs>
<text fill="#E17FDA" transform="translate(-155 -594)" font-size="72" font-family="Source Sans Pro" x="165, 204.16406" y="669">
Aa
</text>
<g mask="url(#tmask1)"><g mask="url(#txtstrokemask0)"><image href="images/test-fill.svg" x="119.5" y="604" width="172" height="86" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -155 -594)"/></g></g></svg>

View File

@ -0,0 +1,15 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="562" height="242" viewBox="0 0 562 242"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs>
<text fill="#E17FDA" transform="translate(1 1)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<g opacity="0.5019608">
<defs/>
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(1 1)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</g></svg>

View File

@ -0,0 +1,16 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="568" height="248" viewBox="0 0 568 248"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="txtstrokemask0" maskUnits="userSpaceOnUse">
<rect transform="translate(4 4)" x="-100000" y="-100000" width="200000" height="200000"/>
<defs/>
<text fill="none" stroke="white" stroke-width="8" stroke-miterlimit="4" transform="translate(4 4)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</mask></defs>
<text fill="#E17FDA" transform="translate(4 4)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<g mask="url(#txtstrokemask0)"><image href="images/test-fill.svg" x="0" y="-20" width="560" height="280" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 4 4)"/></g></svg>

View File

@ -0,0 +1,14 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="562" height="242" viewBox="0 0 562 242"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs>
<text fill="#E17FDA" transform="translate(1 1)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<defs/>
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(1 1)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</svg>

View File

@ -0,0 +1,20 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="txtstrokemask0" maskUnits="userSpaceOnUse">
<rect x="-100000" y="-100000" width="200000" height="200000"/>
<defs/>
<text fill="none" stroke="white" stroke-width="16" stroke-miterlimit="4" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</mask><clipPath id="tclip1" clipPathUnits="userSpaceOnUse">
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</clipPath></defs>
<text fill="#E17FDA" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<g clip-path="url(#tclip1)"><g mask="url(#txtstrokemask0)"><image href="images/test-fill.svg" x="0" y="-20" width="560" height="280" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></g></svg>

View File

@ -0,0 +1,19 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="tclip0" clipPathUnits="userSpaceOnUse">
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</clipPath></defs>
<text fill="#E17FDA" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<g clip-path="url(#tclip0)">
<defs/>
<text fill="none" stroke="blue" stroke-width="4" stroke-miterlimit="4" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</g></svg>

View File

@ -0,0 +1,21 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="580" height="260" viewBox="0 0 580 260"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="txtstrokemask0" maskUnits="userSpaceOnUse">
<rect transform="translate(10 10)" x="-100000" y="-100000" width="200000" height="200000"/>
<defs/>
<text fill="none" stroke="white" stroke-width="20" stroke-miterlimit="4" transform="translate(10 10)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</mask><mask id="tmask1" maskUnits="userSpaceOnUse">
<rect fill="white" transform="translate(10 10)" x="-100000" y="-100000" width="200000" height="200000"/>
<text transform="translate(10 10)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</mask></defs>
<text fill="#E17FDA" transform="translate(10 10)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<g mask="url(#tmask1)"><g mask="url(#txtstrokemask0)"><image href="images/test-fill.svg" x="0" y="-20" width="560" height="280" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 10 10)"/></g></g></svg>

View File

@ -0,0 +1,20 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="572" height="252" viewBox="0 0 572 252"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><mask id="tmask0" maskUnits="userSpaceOnUse">
<rect fill="white" transform="translate(6 6)" x="-100000" y="-100000" width="200000" height="200000"/>
<text transform="translate(6 6)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</mask></defs>
<text fill="#E17FDA" transform="translate(6 6)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<g mask="url(#tmask0)">
<defs/>
<text fill="none" stroke="blue" stroke-width="12" stroke-miterlimit="4" transform="translate(6 6)" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</g></svg>

View File

@ -1944,6 +1944,282 @@ fn exports_solid_text_with_font_face() {
insta::assert_snapshot!(svg);
}
/// Center text stroke: stroked `<text>` (SkSVGDevice keeps stroke style).
#[test]
fn exports_text_with_solid_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((StrokeKind::Center, 2.0, skia::Color::BLUE)),
);
let svg = render(&pool, id);
assert!(svg.contains("<text"), "text glyphs must be present: {svg}");
assert!(
svg.contains("stroke-width=\"2\"") && svg.contains("stroke=\"blue\""),
"center text stroke must be present: {svg}"
);
insta::assert_snapshot!(svg);
}
/// Semi-transparent center stroke: paint is opaque; alpha is `<g opacity>`.
#[test]
fn exports_text_with_alpha_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((
StrokeKind::Center,
2.0,
skia::Color::from_argb(128, 0, 0, 255),
)),
);
let svg = render(&pool, id);
// 128/255 → Skia emits ~0.5019608, not a rounded 0.5.
assert!(
svg.contains("opacity=\"0.5"),
"alpha center stroke must wrap stroke in <g opacity>: {svg}"
);
assert!(
svg.contains("stroke=\"blue\"") || svg.contains("stroke=\"#0000ff\""),
"stroke paint must stay fully opaque: {svg}"
);
insta::assert_snapshot!(svg);
}
/// Inner text stroke: double-width stroke clipped to the glyph silhouette.
#[test]
fn exports_text_with_solid_inner_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((StrokeKind::Inner, 2.0, skia::Color::BLUE)),
);
let svg = render(&pool, id);
assert!(svg.contains("<text"), "text glyphs must be present: {svg}");
assert!(
svg.contains("<clipPath") && svg.contains("clip-path=\"url(#tclip"),
"inner text stroke must use a glyph-silhouette clip: {svg}"
);
insta::assert_snapshot!(svg);
}
/// Outer text stroke: double-width stroke under an inverse-glyph luminance mask.
#[test]
fn exports_text_with_solid_outer_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((StrokeKind::Outer, 6.0, skia::Color::BLUE)),
);
let svg = render(&pool, id);
assert!(svg.contains("<text"), "text glyphs must be present: {svg}");
assert!(
svg.contains("<mask") && svg.contains("mask=\"url(#tmask"),
"outer text stroke must use an exterior glyph mask: {svg}"
);
assert!(
svg.contains("stroke=\"blue\""),
"outer text stroke color must be present: {svg}"
);
insta::assert_snapshot!(svg);
}
fn assert_linked_text_image_stroke(svg: &str) {
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"text image stroke must emit a linked <image>: {svg}"
);
// SVG clipPath ignores strokes; the silhouette must be a luminance mask.
assert!(
svg.contains("<mask") && svg.contains("mask=\"url(#txtstrokemask"),
"text image stroke must mask to the stroke silhouette: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the stroke image: {svg}"
);
}
#[test]
fn exports_text_with_solid_center_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_text_with_image_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
image_solid_stroke(StrokeKind::Center, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_text_image_stroke(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_text_with_solid_inner_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_text_with_image_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
image_solid_stroke(StrokeKind::Inner, 8.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_text_image_stroke(&svg);
assert!(
svg.contains("clip-path=\"url(#tclip"),
"inner text image stroke must also clip to glyphs: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_text_with_solid_outer_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_text_with_image_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
image_solid_stroke(StrokeKind::Outer, 10.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_text_image_stroke(&svg);
assert!(
svg.contains("mask=\"url(#tmask"),
"outer image stroke must nest inverse-glyph mask like solid outer: {svg}"
);
insta::assert_snapshot!(svg);
}
/// Regression: outer image stroke must remain aligned when the text is not at
/// the page origin (page translate in CTM). Uses a luminance mask because SVG
/// clipPath ignores strokes.
#[test]
fn exports_offset_text_with_outer_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_text_with_image_stroke(
&mut pool,
id,
(165.0, 604.0, 246.0, 690.0),
"Aa",
72.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
image_solid_stroke(StrokeKind::Outer, 10.0, image_id),
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_text_image_stroke(&svg);
// Cover-map onto selrect (GPU get_fill_shader), not selrect+delta stretch.
// ImageFill fixture is 200×100; selrect 81×86 → scale 0.86 → dest 172×86 at x=119.5.
assert!(
svg.contains(r#"x="119.5""#) && svg.contains(r#"y="604""#),
"image dest must cover-map onto selrect: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"text stroke images must cover like GPU shaders: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_nofill_text_with_outer_image_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
// No solid fill — only an outer image stroke (matches user report).
add_text_with_fills(
&mut pool,
id,
(165.0, 604.0, 246.0, 690.0),
"Aa",
72.0,
vec![],
);
{
let shape = pool.get_mut(&id).expect("text");
shape.add_stroke(image_solid_stroke(StrokeKind::Outer, 10.0, image_id));
}
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert_linked_text_image_stroke(&svg);
assert!(
svg.contains("mask=\"url(#tmask"),
"nofill outer must nest inverse-glyph mask: {svg}"
);
// No fill <text> in the body — only the masked image.
let body = svg.split("</defs>").last().unwrap_or("");
assert!(
!body.contains("fill=\"#"),
"nofill text must not emit a solid fill: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_text() {
let mut pool = ShapesPool::new();

View File

@ -1,18 +1,23 @@
use std::collections::HashSet;
use skia_safe::Canvas;
use crate::error::Result;
use crate::render::text;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::shapes::{Fill, ImageFill, Shape, SolidColor, Stroke, StrokeKind, Type};
use crate::uuid::Uuid;
use super::document::SvgLayerCanvas;
use super::images::{emit_linked_image_element, xml_escape_attr};
use crate::render::RenderResources;
/// Oversized white rect for inverse luminance masks (page units).
const MASK_CANVAS: f32 = 100_000.0;
/// Emits a text shape's fills for SVG export.
///
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
/// other fills go through Skia as native `<text>`.
///
/// `draw_matrix` is the leaf CTM (normally `centered_transform`). During a
/// parent drop-shadow silhouette pass it must include the geometric offset
@ -57,6 +62,304 @@ pub(super) fn render_text_fill(
Ok(())
}
/// Emits text strokes that `SkSVGDevice` cannot keep via `save_layer`.
///
/// - **Image-filled:** linked `<image>` under a stroke luminance mask
/// (inner also under a glyph clip; outer also under an inverse-glyph mask).
/// - **Center + opaque:** stroked `<text>` through Skia.
/// - **Center + alpha:** same paint inside `<g opacity>`.
/// - **Inner:** glyph `<clipPath>` + opaque double-width stroke (+ opacity).
/// - **Outer:** inverse-glyph luminance `<mask>` + opaque double-width stroke (+ opacity).
pub(super) fn render_text_strokes(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
// strokes[0] is topmost; draw bottom → top (matches GPU / VectorRenderer).
for stroke in element.visible_strokes().rev() {
if let Fill::Image(image_fill) = &stroke.fill {
if shared.images.source_url(&image_fill.id()).is_some() {
emit_image_text_stroke(builder, shared, element, stroke, image_fill, &draw_matrix)?;
continue;
}
}
match stroke.render_kind(false) {
StrokeKind::Center => emit_center_text_stroke(builder, element, stroke, &draw_matrix)?,
StrokeKind::Inner => emit_inner_text_stroke(builder, element, stroke, &draw_matrix)?,
StrokeKind::Outer => emit_outer_text_stroke(builder, element, stroke, &draw_matrix)?,
}
}
Ok(())
}
/// Linked `<image>` confined to the text-stroke silhouette.
///
/// SVG `clipPath` ignores strokes, so the silhouette is a luminance `<mask>`
/// (white stroke on black). Inner also clips to filled glyphs. Outer nests an
/// inverse-glyph mask (same as solid outer strokes) so only the exterior half
/// of the double-width stroke remains — punching glyphs inside the stroke mask
/// misaligns when fill/stroke paragraph paints differ.
fn emit_image_text_stroke(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
stroke: &Stroke,
image_fill: &ImageFill,
matrix: &skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let kind = stroke.render_kind(false);
let stroke_mask = push_text_stroke_mask(builder, element, stroke, matrix)?;
let glyph_clip = if kind == StrokeKind::Inner {
Some(push_glyph_clip(builder, element, matrix)?)
} else {
None
};
let outer_mask = if kind == StrokeKind::Outer {
Some(push_inverse_glyph_mask(builder, element, matrix)?)
} else {
None
};
if let Some(mask_id) = &outer_mask {
builder.open_group(&format!("mask=\"url(#{mask_id})\""));
}
if let Some(clip_id) = &glyph_clip {
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
builder.open_group(&format!("mask=\"url(#{stroke_mask})\""));
let href = xml_escape_attr(url);
// GPU text strokes paint the image via `get_fill_shader` onto `selrect`
// with cover/center scaling — not `selrect + stroke.delta()` stretched.
let dest = text_image_cover_dest(&element.selrect(), image_fill);
emit_linked_image_element(builder, image_fill, dest, &href, None, true, *matrix);
builder.close_group();
if glyph_clip.is_some() {
builder.close_group();
}
if outer_mask.is_some() {
builder.close_group();
}
Ok(())
}
/// Cover-centered dest matching [`crate::shapes::get_fill_shader`] for images.
fn text_image_cover_dest(selrect: &skia_safe::Rect, image_fill: &ImageFill) -> skia_safe::Rect {
let image_width = image_fill.width() as f32;
let image_height = image_fill.height() as f32;
let scale_x = selrect.width() / image_width;
let scale_y = selrect.height() / image_height;
let scale = scale_x.max(scale_y);
let scaled_width = image_width * scale;
let scaled_height = image_height * scale;
let pos_x = selrect.left() - (scaled_width - selrect.width()) / 2.0;
let pos_y = selrect.top() - (scaled_height - selrect.height()) / 2.0;
skia_safe::Rect::from_xywh(pos_x, pos_y, scaled_width, scaled_height)
}
fn push_glyph_clip(
builder: &mut SvgLayerCanvas,
element: &Shape,
matrix: &skia_safe::Matrix,
) -> Result<String> {
let clip_id = builder.unique("tclip");
let canvas = builder.new_fragment();
{
let cv: &Canvas = &canvas;
cv.save();
cv.concat(matrix);
paint_text_glyph_silhouette(cv, element)?;
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
Ok(clip_id)
}
/// Luminance mask of the text-stroke silhouette (masks honor `stroke`; clipPaths do not).
///
/// Black canvas + white stroke. Outer confinement (exterior half only) is applied
/// by nesting [`push_inverse_glyph_mask`], matching solid outer text strokes.
fn push_text_stroke_mask(
builder: &mut SvgLayerCanvas,
element: &Shape,
stroke: &Stroke,
matrix: &skia_safe::Matrix,
) -> Result<String> {
let mask_id = builder.unique("txtstrokemask");
let canvas = builder.new_fragment();
{
let cv: &Canvas = &canvas;
let mut black = skia_safe::Paint::default();
black.set_color(skia_safe::Color::BLACK);
cv.draw_rect(
skia_safe::Rect::from_ltrb(-MASK_CANVAS, -MASK_CANVAS, MASK_CANVAS, MASK_CANVAS),
&black,
);
cv.save();
cv.concat(matrix);
let mut silhouette = stroke.clone();
silhouette.fill = Fill::Solid(SolidColor(skia_safe::Color::WHITE));
paint_text_stroke_opaque(cv, element, &silhouette)?;
cv.restore();
}
builder.finish_mask_fragment(&mask_id, canvas);
Ok(mask_id)
}
fn emit_center_text_stroke(
builder: &mut SvgLayerCanvas,
element: &Shape,
stroke: &Stroke,
matrix: &skia_safe::Matrix,
) -> Result<()> {
let opacity = stroke.fill.opacity();
if opacity < 1.0 {
builder.open_group(&format!("opacity=\"{opacity}\""));
}
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(matrix);
paint_text_stroke_opaque(canvas, element, stroke)?;
canvas.restore();
}
if opacity < 1.0 {
builder.close_group();
}
Ok(())
}
fn emit_inner_text_stroke(
builder: &mut SvgLayerCanvas,
element: &Shape,
stroke: &Stroke,
matrix: &skia_safe::Matrix,
) -> Result<()> {
let clip_id = builder.unique("tclip");
let canvas = builder.new_fragment();
{
let cv: &Canvas = &canvas;
cv.save();
cv.concat(matrix);
paint_text_glyph_silhouette(cv, element)?;
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let opacity = stroke.fill.opacity();
let mut attrs = format!("clip-path=\"url(#{clip_id})\"");
if opacity < 1.0 {
attrs.push_str(&format!(" opacity=\"{opacity}\""));
}
builder.open_group(&attrs);
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(matrix);
paint_text_stroke_opaque(canvas, element, stroke)?;
canvas.restore();
}
builder.close_group();
Ok(())
}
fn emit_outer_text_stroke(
builder: &mut SvgLayerCanvas,
element: &Shape,
stroke: &Stroke,
matrix: &skia_safe::Matrix,
) -> Result<()> {
let mask_id = push_inverse_glyph_mask(builder, element, matrix)?;
let opacity = stroke.fill.opacity();
let mut attrs = format!("mask=\"url(#{mask_id})\"");
if opacity < 1.0 {
attrs.push_str(&format!(" opacity=\"{opacity}\""));
}
builder.open_group(&attrs);
{
let canvas = builder.canvas();
canvas.save();
canvas.concat(matrix);
paint_text_stroke_opaque(canvas, element, stroke)?;
canvas.restore();
}
builder.close_group();
Ok(())
}
/// White canvas minus black glyphs: keeps only content outside the glyphs.
fn push_inverse_glyph_mask(
builder: &mut SvgLayerCanvas,
element: &Shape,
matrix: &skia_safe::Matrix,
) -> Result<String> {
let mask_id = builder.unique("tmask");
let canvas = builder.new_fragment();
{
let cv: &Canvas = &canvas;
let mut white = skia_safe::Paint::default();
white.set_color(skia_safe::Color::WHITE);
cv.draw_rect(
skia_safe::Rect::from_ltrb(-MASK_CANVAS, -MASK_CANVAS, MASK_CANVAS, MASK_CANVAS),
&white,
);
cv.save();
cv.concat(matrix);
paint_text_glyph_silhouette(cv, element)?;
cv.restore();
}
builder.finish_mask_fragment(&mask_id, canvas);
Ok(mask_id)
}
/// Opaque glyph fill silhouette (clip/mask source).
fn paint_text_glyph_silhouette(canvas: &Canvas, shape: &Shape) -> Result<()> {
let Type::Text(text_content) = &shape.shape_type else {
return Ok(());
};
let text_content = text_content.new_bounds(shape.selrect());
let mut mask_builders = text_content.paragraph_builder_group_opaque();
text::render_overlay_emoji(canvas, shape, &mut mask_builders, None, None, None, None)?;
Ok(())
}
/// Single text stroke at full paint opacity (no save_layer opacity / masks).
///
/// Inner/Outer still paint at double width; the SVG compositor supplies
/// clipPath / mask. Builders already peel stroke-fill alpha into `layer_opacity`.
fn paint_text_stroke_opaque(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> Result<()> {
let Type::Text(text_content) = &shape.shape_type else {
return Ok(());
};
let text_content = text_content.new_bounds(shape.selrect());
let stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
let (mut stroke_paragraphs, _) = text::stroke_paragraph_builder_group_from_text(
&text_content,
stroke,
&shape.selrect(),
None,
);
text::render_with_bounds_outset_overlay_emoji(
canvas,
shape,
&mut stroke_paragraphs,
None,
None,
stroke_blur_outset,
None,
None,
)?;
Ok(())
}
fn linked_image_fills_at_layer<'a>(
text_content: &'a crate::shapes::TextContent,
layer: usize,
@ -113,7 +416,7 @@ fn emit_text_image_fill(
let canvas = builder.new_fragment();
{
let cv: &skia_safe::Canvas = &canvas;
let cv: &Canvas = &canvas;
cv.save();
cv.concat(&draw_matrix);
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
@ -124,11 +427,11 @@ fn emit_text_image_fill(
let href = xml_escape_attr(url);
emit_linked_image_element(
builder,
shape,
image_fill,
shape.selrect(),
&href,
&clip_id,
Some(&clip_id),
true,
draw_matrix,
);
Ok(())