mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
✨ 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:
parent
76e9289fcf
commit
b8af89c1b8
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
|
||||
@ -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(())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user