diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 538a91b053..ca051f7e09 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -241,6 +241,21 @@ impl SvgLayerCanvas { )); id } + + /// Finalizes a fragment canvas as a luminance `` 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!( + "{geometry}" + )); + } } fn color_matrix_values(color: skia::Color) -> String { diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index e97c39c36f..e7a8b8bd80 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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> = 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); diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs index eadef967af..81e71a485e 100644 --- a/render-wasm/src/render/svg/images.rs +++ b/render-wasm/src/render/svg/images.rs @@ -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 `` + `` at `dest_rect`, under the page CTM. +/// Emits `` at `dest_rect`, under the page CTM. +/// +/// When `clip_id` is set, wraps the image in ``. 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#""#, 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 { diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 95d49b7d34..292ac61461 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -61,9 +61,11 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// ``. /// /// Layer blur and drop/inner shadows are re-emitted as a native SVG `` -/// 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 `` clipped to the stroke. +/// image-filled strokes use a linked `` clipped to the stroke; +/// text strokes use `` / glyph clipPath / inverse glyph mask, and +/// image-filled text strokes use a linked `` 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(); } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_nofill_text_with_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_nofill_text_with_outer_image_stroke.snap new file mode 100644 index 0000000000..e020297fb6 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_nofill_text_with_outer_image_stroke.snap @@ -0,0 +1,17 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + Aa + + + + + Aa + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_offset_text_with_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_offset_text_with_outer_image_stroke.snap new file mode 100644 index 0000000000..5471004cad --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_offset_text_with_outer_image_stroke.snap @@ -0,0 +1,21 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + Aa + + + + + Aa + + + + Aa + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_alpha_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_alpha_center_stroke.snap new file mode 100644 index 0000000000..66d68f5ac4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_alpha_center_stroke.snap @@ -0,0 +1,15 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_center_image_stroke.snap new file mode 100644 index 0000000000..af1eac87df --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_center_image_stroke.snap @@ -0,0 +1,16 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + HOLA + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_center_stroke.snap new file mode 100644 index 0000000000..1813878249 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_center_stroke.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_inner_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_inner_image_stroke.snap new file mode 100644 index 0000000000..1f2755df87 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_inner_image_stroke.snap @@ -0,0 +1,20 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + HOLA + + + + HOLA + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..615fa95fd2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_inner_stroke.snap @@ -0,0 +1,19 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + + + HOLA + + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_outer_image_stroke.snap new file mode 100644 index 0000000000..c3325c811d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_outer_image_stroke.snap @@ -0,0 +1,21 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + HOLA + + + + + HOLA + + + + HOLA + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..e160e2875e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_solid_outer_stroke.snap @@ -0,0 +1,20 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + HOLA + + + + HOLA + + + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index c15ca03048..cae1ccfefa 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1944,6 +1944,282 @@ fn exports_solid_text_with_font_face() { insta::assert_snapshot!(svg); } +/// Center text stroke: stroked `` (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("`. +#[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 : {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(": {svg}" + ); + // SVG clipPath ignores strokes; the silhouette must be a luminance mask. + assert!( + svg.contains(" in the body — only the masked image. + let body = svg.split("").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(); diff --git a/render-wasm/src/render/svg/text.rs b/render-wasm/src/render/svg/text.rs index 054c0e53b4..430e6155e6 100644 --- a/render-wasm/src/render/svg/text.rs +++ b/render-wasm/src/render/svg/text.rs @@ -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 `` clipped to the glyph silhouette; -/// other fills go through Skia as native ``. Strokes are a later PR. +/// other fills go through Skia as native ``. /// /// `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 `` under a stroke luminance mask +/// (inner also under a glyph clip; outer also under an inverse-glyph mask). +/// - **Center + opaque:** stroked `` through Skia. +/// - **Center + alpha:** same paint inside ``. +/// - **Inner:** glyph `` + opaque double-width stroke (+ opacity). +/// - **Outer:** inverse-glyph luminance `` + 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 `` confined to the text-stroke silhouette. +/// +/// SVG `clipPath` ignores strokes, so the silhouette is a luminance `` +/// (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 { + 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 { + 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 { + 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(())