diff --git a/crates/neomacs-font-materializer/src/color_glyph/colr.rs b/crates/neomacs-font-materializer/src/color_glyph/colr.rs index 7f8ba67f61..91bde6359f 100644 --- a/crates/neomacs-font-materializer/src/color_glyph/colr.rs +++ b/crates/neomacs-font-materializer/src/color_glyph/colr.rs @@ -769,6 +769,12 @@ pub(super) fn paint_colr_glyph( if recorder.malformed || recorder.steps.is_empty() { return None; } + // Spec: a color glyph definition must be bounded — "applications must + // confirm that the color glyph definition is bounded, and must not render + // the color glyph if the defining graph is not bounded." + if !graph_is_bounded(&recorder.steps) { + return None; + } let scale = request.px_size / units_per_em; if !scale.is_finite() || scale <= 0.0 { @@ -1094,6 +1100,66 @@ fn replay_geometry<'a>( } } +/// Whether the recorded graph paints a bounded region, per the spec's +/// per-format boundedness rules. +/// +/// A fill is bounded by a clip in force or by its own shape; a composite is +/// bounded only per the format-32 table, which can bound a sub-graph whose +/// own fills are unbounded (or unbind one whose fills are not). The +/// composite driver records its two layers as adjacent pushes — backdrop, +/// source — and pops them adjacently, which is what identifies a composite +/// here. +fn graph_is_bounded(steps: &[Step]) -> bool { + // (mode of the layer, whether it contains an unbounded fill or composite) + let mut scopes: Vec<(CompositeMode, bool)> = vec![(CompositeMode::SourceOver, false)]; + for (index, step) in steps.iter().enumerate() { + match step { + Step::Fill { bounded: false, .. } => { + if let Some(scope) = scopes.last_mut() { + scope.1 = true; + } + } + Step::PushLayer(mode) => scopes.push((*mode, false)), + Step::PopLayer => { + let Some((mode, source_unbounded)) = scopes.pop() else { + return false; + }; + if matches!(steps.get(index + 1), Some(Step::PopLayer)) { + // This pops a composite's source layer; the next pops the + // backdrop wrapper it was composed onto. + let Some(backdrop) = scopes.last_mut() else { + return false; + }; + backdrop.1 = !composite_is_bounded(mode, !source_unbounded, !backdrop.1); + } else if let Some(parent) = scopes.last_mut() { + parent.1 |= source_unbounded; + } + } + _ => {} + } + } + // The root scope's flag: bounded when nothing unbounded escaped to it. + scopes.first().is_some_and(|(_, unbounded)| !unbounded) +} + +/// The format-32 boundedness table. +fn composite_is_bounded(mode: CompositeMode, source: bool, backdrop: bool) -> bool { + match mode { + // Always bounded. + CompositeMode::Clear => true, + CompositeMode::Source | CompositeMode::SourceOut => source, + CompositeMode::Destination | CompositeMode::DestinationOut => backdrop, + // Porter-Duff output alpha: source-atop paints only where the + // backdrop is opaque, destination-atop only where the source is — + // stronger than the table's "all other modes" fallback, which would + // reject these graphs even when one operand bounds them. + CompositeMode::SourceAtop => backdrop, + CompositeMode::DestinationAtop => source, + CompositeMode::SourceIn | CompositeMode::DestinationIn => source || backdrop, + _ => source && backdrop, + } +} + /// Apply a step's graph transform on top of the device transform. fn resolve<'p>( paths: &'p [Path], @@ -1126,16 +1192,49 @@ impl<'a> RasterState<'a> { paint, bounded, } => { - if !*bounded { - // The spec forbids rendering an unbounded fill; the - // record pass already drops a glyph containing one. - tracing::trace!(target: "font_boundary", "skipping unbounded color fill"); - continue; - } - let Some((path, shape_transform, source)) = - replay_geometry(self.paths, &self.clips, shape) - else { - continue; + let geometry = replay_geometry(self.paths, &self.clips, shape); + // A fill the graph leaves unbounded — which the whole + // graph check only lets through inside a composite whose + // mode bounds it by the other operand — paints the whole + // surface, in the space its paint was recorded in. + let surface_path: Option = match (&geometry, *bounded) { + (None, false) => { + let matrix = self.transform.pre_concat(*paint_transform); + let Some(inverse) = matrix.invert() else { + continue; + }; + let (width, height) = self.size; + let mut corners = [ + Point::from_xy(0.0, 0.0), + Point::from_xy(width as f32, 0.0), + Point::from_xy(width as f32, height as f32), + Point::from_xy(0.0, height as f32), + ]; + for corner in &mut corners { + inverse.map_point(corner); + } + let x_min = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min); + let x_max = corners + .iter() + .map(|p| p.x) + .fold(f32::NEG_INFINITY, f32::max); + let y_min = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min); + let y_max = corners + .iter() + .map(|p| p.y) + .fold(f32::NEG_INFINITY, f32::max); + let Some(rect) = tiny_skia::Rect::from_ltrb(x_min, y_min, x_max, y_max) + else { + continue; + }; + Some(PathBuilder::from_rect(rect)) + } + _ => None, + }; + let (path, shape_transform, source) = match (&geometry, &surface_path) { + (Some((path, transform, source)), _) => (*path, *transform, Some(*source)), + (None, Some(path)) => (path, *paint_transform, None), + (None, None) => continue, }; // The paint is placed relative to the shape: inside a // PaintGlyph a transform moves the child paint, while the @@ -1155,23 +1254,27 @@ impl<'a> RasterState<'a> { // path already carries its coverage, and multiplying it in // again would square every antialiased edge. let mut folded: Option = None; - let clip: Option<&Mask> = if self - .clips - .iter() - .any(|frame| frame.source.same_geometry(&source, self.paths)) - { - for frame in &self.clips { - if frame.source.same_geometry(&source, self.paths) { - continue; - } - match &mut folded { - Some(mask) => intersect_masks(&frame.own, mask), - None => folded = Some(frame.own.clone()), + let clip: Option<&Mask> = if let Some(source) = &source { + if self + .clips + .iter() + .any(|frame| frame.source.same_geometry(source, self.paths)) + { + for frame in &self.clips { + if frame.source.same_geometry(source, self.paths) { + continue; + } + match &mut folded { + Some(mask) => intersect_masks(&frame.own, mask), + None => folded = Some(frame.own.clone()), + } } + folded.as_ref() + } else { + self.clips.last().map(|frame| &frame.cumulative) } - folded.as_ref() } else { - self.clips.last().map(|frame| &frame.cumulative) + None }; let transform = self.transform.pre_concat(shape_transform); let target = match self.layers.last_mut() { diff --git a/crates/neomacs-font-materializer/src/color_glyph/tests/synthetic_colr_test.rs b/crates/neomacs-font-materializer/src/color_glyph/tests/synthetic_colr_test.rs index 9926fc0ca0..da082dc5b6 100644 --- a/crates/neomacs-font-materializer/src/color_glyph/tests/synthetic_colr_test.rs +++ b/crates/neomacs-font-materializer/src/color_glyph/tests/synthetic_colr_test.rs @@ -840,9 +840,9 @@ fn inkless_clip_hides_its_subgraph() { } /// A bare fill after a `pop_clip` has no shape of its own any more. Nothing -/// bounds it — no clip box and no `PaintGlyph` ancestor — so the spec forbids -/// rendering it, and it must not silently keep painting the previous layer's -/// outline. +/// bounds it — no clip box and no `PaintGlyph` ancestor — so the color glyph +/// definition is unbounded, and the spec requires the whole glyph not to +/// render, not just the bare fill to be skipped. #[test] fn a_bare_layer_fill_has_no_inherited_outline() { let (base, layer) = (glyph('A'), glyph('H')); @@ -851,32 +851,14 @@ fn a_bare_layer_fill_has_no_inherited_outline() { paint_solid(1, 1.0), ]; let root = paint_colr_layers(0, 2); - let with_bare = font_with_color_tables( + let unbounded = font_with_color_tables( colr_v1_table_with_layers(base, &root, &layers, None), cpal_table(&[RED, GREEN]), ); - let layered_only = font_with_color_tables( - colr_v1_table_with_layers(base, &paint_colr_layers(0, 1), &layers[..1], None), - cpal_table(&[RED, GREEN]), - ); - let with_bare = rasterize(&with_bare, "synthetic:stale-outline", base, [0, 0, 0, 255]) - .expect("the shaped layer still paints"); - let layered_only = rasterize(&layered_only, "synthetic:layers-only", base, [0, 0, 0, 255]) - .expect("the shaped layer still paints"); assert_eq!( - (with_bare.width, with_bare.height), - (layered_only.width, layered_only.height) - ); - let worst = with_bare - .rgba - .iter() - .zip(&layered_only.rgba) - .map(|(with_bare, layered_only)| with_bare.abs_diff(*layered_only)) - .max() - .expect("rasters have pixels"); - assert!( - worst == 0, - "the unbounded bare fill painted into the layer list: worst channel delta {worst}" + rasterize(&unbounded, "synthetic:stale-outline", base, [0, 0, 0, 255]), + None, + "an unbounded color glyph must not render at all" ); // The same graph under a clip box is bounded: the bare fill paints the @@ -896,6 +878,49 @@ fn a_bare_layer_fill_has_no_inherited_outline() { assert_close_to(mean_color(&raster), GREEN, 8.0); } +/// Spec format 32: COMPOSITE_SRC_IN is bounded when *either* operand is, so +/// an unbounded backdrop must still paint: it fills the surface under the +/// source, and source-in keeps the artwork. Skipping the unbounded fill +/// would leave the destination transparent and erase the composite's result. +#[test] +fn composite_src_in_paints_its_unbounded_backdrop() { + let base = glyph('A'); + let composite = paint_composite( + &paint_solid(0, 1.0), // backdrop: red, unbounded + 5, // CompositeMode::SourceIn + &paint_glyph(base, &paint_solid(1, 1.0)), // source: bounded green A + ); + let font = font_with_color_tables(colr_v1_table(base, &composite), cpal_table(&[RED, GREEN])); + let raster = rasterize(&font, "synthetic:composite-src-in", base, [0, 0, 0, 255]) + .expect("a bounded composite paints"); + let painted = raster.rgba.chunks_exact(4).filter(|p| p[3] > 0).count(); + assert!( + painted > 100, + "source-in of a full-surface backdrop must keep the source's ink, got {painted} painted pixels" + ); + // The backdrop's alpha is one everywhere, so source-in is the source + // itself: every covered pixel is the source colour, at its own coverage. + assert_close_to(mean_color(&raster), GREEN, 8.0); +} + +/// Spec format 32: COMPOSITE_CLEAR is always bounded, but clearing over +/// nothing observable leaves nothing to show, and the glyph measures empty. +#[test] +fn composite_clear_over_unbounded_fills_measures_empty() { + let base = glyph('A'); + let composite = paint_composite( + &paint_solid(0, 1.0), + 0, // CompositeMode::Clear + &paint_solid(1, 1.0), + ); + let font = font_with_color_tables(colr_v1_table(base, &composite), cpal_table(&[RED, GREEN])); + assert_eq!( + rasterize(&font, "synthetic:composite-clear", base, [0, 0, 0, 255]), + None, + "a clear with no bounded content shows nothing" + ); +} + /// A legal graph can fan out exponentially through the one `LayerList`; /// ttf-parser bounds recursion depth and cycles but not the number of visits, /// so the recorder has to stop instead of allocating millions of steps. @@ -1278,3 +1303,46 @@ fn paint_transform_inside_paint_glyph_moves_the_gradient() { "the transform did not reach the gradient: worst channel delta {worst}" ); } + +/// Porter-Duff: a source-atop output is confined to the backdrop's opaque +/// region, so a bounded backdrop bounds it even when the source is a bare +/// fill. The format-32 fallback would reject this graph outright. +#[test] +fn composite_source_atop_is_bounded_by_its_backdrop() { + let base = glyph('A'); + let composite = paint_composite( + &paint_glyph(base, &paint_solid(0, 1.0)), // backdrop: bounded red A + 9, // CompositeMode::SourceAtop + &paint_solid(1, 1.0), // source: unbounded green + ); + let font = font_with_color_tables(colr_v1_table(base, &composite), cpal_table(&[RED, GREEN])); + let raster = rasterize(&font, "synthetic:composite-src-atop", base, [0, 0, 0, 255]) + .expect("a backdrop-bounded source-atop paints"); + let painted = raster.rgba.chunks_exact(4).filter(|p| p[3] > 0).count(); + assert!( + painted > 100, + "source-atop must keep the backdrop's ink, got {painted} painted pixels" + ); + assert_close_to(mean_color(&raster), GREEN, 8.0); +} + +/// The mirror: a destination-atop output is confined to the source's opaque +/// region, so a bounded source bounds it despite a bare-fill backdrop. +#[test] +fn composite_destination_atop_is_bounded_by_its_source() { + let base = glyph('A'); + let composite = paint_composite( + &paint_solid(0, 1.0), // backdrop: unbounded red + 10, // CompositeMode::DestinationAtop + &paint_glyph(base, &paint_solid(1, 1.0)), // source: bounded green A + ); + let font = font_with_color_tables(colr_v1_table(base, &composite), cpal_table(&[RED, GREEN])); + let raster = rasterize(&font, "synthetic:composite-dest-atop", base, [0, 0, 0, 255]) + .expect("a source-bounded destination-atop paints"); + let painted = raster.rgba.chunks_exact(4).filter(|p| p[3] > 0).count(); + assert!( + painted > 100, + "destination-atop must keep the source's shape, got {painted} painted pixels" + ); + assert_close_to(mean_color(&raster), RED, 8.0); +} diff --git a/lisp/ldefs-boot.el b/lisp/ldefs-boot.el index af44f55488..5c4d160d58 100644 --- a/lisp/ldefs-boot.el +++ b/lisp/ldefs-boot.el @@ -23004,7 +23004,7 @@ Optional COLS, ROWS set the terminal size. ;;; Generated autoloads from neomacs-image.el -(register-definition-prefixes "neomacs-image" '("neomacs-image-")) +(register-definition-prefixes "neomacs-image" '("neomacs-")) ;;; Generated autoloads from neomacs-shader-playground.el