From 9129dcbeca290f5fbbf9d4897be1cab7790a82bc Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Sat, 10 Oct 2026 20:27:20 +0800 Subject: [PATCH 1/2] fix(font): bound a color glyph by the spec's per-format rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boundedness was decided per fill: a fill with no shape and no clip was skipped, and the rest of the graph still rendered. The spec decides it for the whole graph — a bare solid makes the definition unbounded and "must not render" — except inside a composite, where the format-32 table can bound a sub-graph whose own fills are unbounded (source-in needs only one bounded operand, destination-out only a bounded backdrop). The recorder's step list is now analyzed before painting: layer scopes come from the composite driver's backdrop/source pushes with their adjacent pops, each composite applies the table, and anything unbounded that reaches the root drops the glyph. A fill the table lets through paints the whole surface in the space its paint was recorded in, which is what an unbounded fill means once some bounded content has given the surface its size. Verified: materializer 71/71 — the layer-list case now drops the whole glyph, a source-in over a bare solid keeps its ink, and clear over unbounded content measures empty. --- .../src/color_glyph/colr.rs | 145 +++++++++++++++--- .../color_glyph/tests/synthetic_colr_test.rs | 75 ++++++--- 2 files changed, 171 insertions(+), 49 deletions(-) diff --git a/crates/neomacs-font-materializer/src/color_glyph/colr.rs b/crates/neomacs-font-materializer/src/color_glyph/colr.rs index 7f8ba67f61..e97b775351 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,60 @@ 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, + 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 +1186,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 +1248,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..1a059ca347 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. From 2870a16c36c50206680de83534ac950441f6da3a Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Sat, 10 Oct 2026 21:17:55 +0800 Subject: [PATCH 2/2] fix(font): bound the atop composites by their opaque operand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the format-32 table: source-atop and destination-atop were falling through to the both-operands rule. Porter-Duff says their output alpha is the backdrop's and the source's respectively, so a graph with one bounded operand is bounded and must render — rejecting it would have dropped valid color glyphs to the monochrome fallback. Verified: materializer 85/85, the two new tests failing before the rows existed. --- .../src/color_glyph/colr.rs | 6 +++ .../color_glyph/tests/synthetic_colr_test.rs | 43 +++++++++++++++++++ lisp/ldefs-boot.el | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/neomacs-font-materializer/src/color_glyph/colr.rs b/crates/neomacs-font-materializer/src/color_glyph/colr.rs index e97b775351..91bde6359f 100644 --- a/crates/neomacs-font-materializer/src/color_glyph/colr.rs +++ b/crates/neomacs-font-materializer/src/color_glyph/colr.rs @@ -1149,6 +1149,12 @@ fn composite_is_bounded(mode: CompositeMode, source: bool, backdrop: bool) -> bo 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, } 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 1a059ca347..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 @@ -1303,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