mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
fix(text): #8247 colour glyph review findings (clips, budgets, edge cases)
Works through the review threads on the colour glyph painter: - Clips no longer leak into composites: a layer is painted without the clips around it, which apply once when the layer is merged, so a glyph around a SrcIn composite keeps its anti-aliased edges and Src/Clear composites leave pixels outside the clip alone. - Layers, clip masks and fills count against shared budgets: the bytes left beside the accumulator and output (MAX_RASTER_BYTES) and the pixel work left of MAX_PASS_PIXELS. The fixed eight-layer cap that flattened deeper composites (dropping their modes) is gone. - Clip boxes are drawn like the clip glyphs, so bent type bends them instead of dropping them; ink bounds take the intersection of the clips. - Colour glyph clips follow the selected anti-alias mode. - A colour glyph whose paint graph skrifa cannot walk draws its outline; one whose base glyph has no outline still paints its layers. - Coincident colour stops switch at their offset; sweeps whose angles run past 360 degrees sample the right turn of the angle. - Float documents keep extended text colours in colour glyphs. Co-Authored-By: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2eddd344eb
commit
d9102e7e0a
3 files changed
+576
-173
No files matched your search
+445
-145
@@ -6,42 +6,81 @@
|
||||
//! the text raster in premultiplied sRGB, [`OutlineBounds`] only collects where it would paint.
|
||||
|
||||
use photocraft_color::blend::{BlendMode, blend_rgb};
|
||||
use photocraft_doc::text::AntiAlias;
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
|
||||
use skrifa::raw::types::BoundingBox;
|
||||
|
||||
use crate::raster::{Coverage, LineSink, Xform, rect_to};
|
||||
use crate::raster::{Bounds, Coverage, Xform};
|
||||
use crate::render::shape_antialias_coverage;
|
||||
|
||||
/// Palette entry standing for the text colour.
|
||||
const FOREGROUND: u16 = 0xFFFF;
|
||||
/// Layers a glyph may stack (each is a window-sized buffer); deeper ones paint straight into the
|
||||
/// layer below. skrifa itself stops at 64 nested paints.
|
||||
const MAX_LAYERS: usize = 8;
|
||||
|
||||
fn xform(t: &Transform) -> Xform {
|
||||
Xform([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
|
||||
}
|
||||
|
||||
/// Collects the outlines a colour glyph paints within (its clip glyphs and clip boxes) into a
|
||||
/// [`LineSink`], for the ink bounds. `outline` draws a glyph (font units, through the given paint
|
||||
/// transform) into the sink; `to_sink` maps font units to the sink for clip boxes.
|
||||
pub(crate) struct OutlineBounds<'a, S: LineSink, F: FnMut(GlyphId, &Xform, &mut S)> {
|
||||
pub sink: &'a mut S,
|
||||
pub outline: F,
|
||||
pub to_sink: Xform,
|
||||
xforms: Vec<Xform>,
|
||||
/// What a colour glyph clips to: a glyph outline or a COLR clip box, both in font units through
|
||||
/// the paint transform. The caller draws either one the same way (bent with the type, for
|
||||
/// warped text), so a clip box follows the glyph it crops.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Shape {
|
||||
Glyph(GlyphId),
|
||||
Box(BoundingBox<f32>),
|
||||
}
|
||||
|
||||
impl<'a, S: LineSink, F: FnMut(GlyphId, &Xform, &mut S)> OutlineBounds<'a, S, F> {
|
||||
pub fn new(sink: &'a mut S, outline: F, to_sink: Xform) -> Self {
|
||||
Self { sink, outline, to_sink, xforms: Vec::new() }
|
||||
/// A painter that ignores everything, to check that a colour glyph's paint graph can be walked
|
||||
/// before the glyph is drawn in colour ([`crate::render`] draws its outline otherwise).
|
||||
pub(crate) struct NoPaint;
|
||||
|
||||
impl ColorPainter for NoPaint {
|
||||
fn push_transform(&mut self, _: Transform) {}
|
||||
fn pop_transform(&mut self) {}
|
||||
fn push_clip_glyph(&mut self, _: GlyphId) {}
|
||||
fn push_clip_box(&mut self, _: BoundingBox<f32>) {}
|
||||
fn pop_clip(&mut self) {}
|
||||
fn fill(&mut self, _: Brush<'_>) {}
|
||||
fn push_layer(&mut self, _: CompositeMode) {}
|
||||
}
|
||||
|
||||
/// Collects where a colour glyph paints, for the ink bounds: every fill adds the intersection of
|
||||
/// the clips around it, so a large clip box around small artwork does not widen the bounds.
|
||||
/// `outline` draws a [`Shape`] (through the given paint transform) into a [`Bounds`] in sink
|
||||
/// space. A fill outside every clip is unbounded and adds nothing.
|
||||
pub(crate) struct OutlineBounds<'a, F: FnMut(Shape, &Xform, &mut Bounds)> {
|
||||
pub sink: &'a mut Bounds,
|
||||
pub outline: F,
|
||||
xforms: Vec<Xform>,
|
||||
/// The intersection of the clips so far; `None` when it is empty.
|
||||
clips: Vec<Option<[f64; 4]>>,
|
||||
}
|
||||
|
||||
impl<'a, F: FnMut(Shape, &Xform, &mut Bounds)> OutlineBounds<'a, F> {
|
||||
pub fn new(sink: &'a mut Bounds, outline: F) -> Self {
|
||||
Self { sink, outline, xforms: Vec::new(), clips: Vec::new() }
|
||||
}
|
||||
fn current(&self) -> Xform {
|
||||
self.xforms.last().copied().unwrap_or(Xform::IDENTITY)
|
||||
}
|
||||
fn push(&mut self, shape: Shape) {
|
||||
let mut b = Bounds::default();
|
||||
let m = self.current();
|
||||
(self.outline)(shape, &m, &mut b);
|
||||
let r = match (self.clips.last(), b.rect) {
|
||||
(_, None) => None,
|
||||
(None, r) => r,
|
||||
(Some(None), _) => None,
|
||||
(Some(Some(o)), Some(r)) => {
|
||||
let i = [o[0].max(r[0]), o[1].max(r[1]), o[2].min(r[2]), o[3].min(r[3])];
|
||||
(i[0] <= i[2] && i[1] <= i[3]).then_some(i)
|
||||
}
|
||||
};
|
||||
self.clips.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: LineSink, F: FnMut(GlyphId, &Xform, &mut S)> ColorPainter for OutlineBounds<'_, S, F> {
|
||||
impl<F: FnMut(Shape, &Xform, &mut Bounds)> ColorPainter for OutlineBounds<'_, F> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
self.xforms.push(self.current().mul(&xform(&t)));
|
||||
}
|
||||
@@ -49,15 +88,20 @@ impl<S: LineSink, F: FnMut(GlyphId, &Xform, &mut S)> ColorPainter for OutlineBou
|
||||
self.xforms.pop();
|
||||
}
|
||||
fn push_clip_glyph(&mut self, id: GlyphId) {
|
||||
let m = self.current();
|
||||
(self.outline)(id, &m, self.sink);
|
||||
self.push(Shape::Glyph(id));
|
||||
}
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
let m = self.to_sink.mul(&self.current());
|
||||
rect_to(self.sink, &m, b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
self.push(Shape::Box(b));
|
||||
}
|
||||
fn pop_clip(&mut self) {
|
||||
self.clips.pop();
|
||||
}
|
||||
fn fill(&mut self, _: Brush<'_>) {
|
||||
if let Some(Some([x0, y0, x1, y1])) = self.clips.last().copied() {
|
||||
self.sink.add((x0, y0));
|
||||
self.sink.add((x1, y1));
|
||||
}
|
||||
}
|
||||
fn pop_clip(&mut self) {}
|
||||
fn fill(&mut self, _: Brush<'_>) {}
|
||||
fn push_layer(&mut self, _: CompositeMode) {}
|
||||
}
|
||||
|
||||
@@ -81,20 +125,19 @@ impl ColorLine {
|
||||
}
|
||||
_ => t,
|
||||
};
|
||||
if t.is_nan() || t <= first.0 {
|
||||
if t.is_nan() || t < first.0 {
|
||||
return first.1;
|
||||
}
|
||||
if t >= last.0 {
|
||||
return last.1;
|
||||
}
|
||||
for pair in self.stops.windows(2) {
|
||||
let [(o0, c0), (o1, c1)] = [pair[0], pair[1]];
|
||||
if t <= o1 {
|
||||
let k = if o1 > o0 { (t - o0) / (o1 - o0) } else { 1.0 };
|
||||
return std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * k);
|
||||
}
|
||||
}
|
||||
last.1
|
||||
// The last stop at or before `t`: where several stops share an offset, the last of them
|
||||
// holds from that offset on (OpenType COLR, colour lines), so a pixel landing exactly on
|
||||
// a hard transition takes the colour after it. The next stop then lies strictly after.
|
||||
let i = self.stops.partition_point(|s| s.0 <= t);
|
||||
let (Some(&(o0, c0)), Some(&(o1, c1))) = (i.checked_sub(1).and_then(|i| self.stops.get(i)), self.stops.get(i)) else { return last.1 };
|
||||
let k = if o1 > o0 { (t - o0) / (o1 - o0) } else { 1.0 };
|
||||
std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +179,13 @@ impl Paint {
|
||||
Paint::Sweep { c, start, end, line } => {
|
||||
// skrifa hands the angles clockwise (360° minus the font's counter-clockwise ones).
|
||||
let ccw = (v - c.1).atan2(u - c.0).to_degrees();
|
||||
let cw = (360.0 - ccw).rem_euclid(360.0);
|
||||
// skrifa's start and end may lie outside 0..360 (a sweep from 270° to 450°
|
||||
// crosses the positive x axis): take the turn of the angle that falls inside them.
|
||||
let mut cw = (360.0 - ccw).rem_euclid(360.0);
|
||||
let turn = cw + 360.0 * ((start - cw) / 360.0).ceil();
|
||||
if turn.is_finite() && turn <= *end {
|
||||
cw = turn;
|
||||
}
|
||||
let span = end - start;
|
||||
let t = if span.abs() > 1e-6 {
|
||||
(cw - start) / span
|
||||
@@ -202,29 +251,46 @@ fn composite(mode: CompositeMode, b: [f32; 4], s: [f32; 4]) -> [f32; 4] {
|
||||
std::array::from_fn(|i| (s[i] * fs + b[i] * fb).min(1.0))
|
||||
}
|
||||
|
||||
/// Paints one colour glyph into a `w × h` window, premultiplied sRGB. `outline` adds a glyph's
|
||||
/// coverage (font units through the given paint transform) to a window-sized [`Coverage`];
|
||||
/// `to_window` maps font units into the window (gradients and clip boxes).
|
||||
pub(crate) struct Painter<'a, F: FnMut(GlyphId, &Xform, &mut Coverage)> {
|
||||
/// What one render may still spend on colour glyphs, shared by all of them: `bytes` of buffers
|
||||
/// live at once (the window layers and clip masks), and `work`, pixels touched by fills, clips
|
||||
/// and composites, against the same cumulative budget as the plain colour passes. A glyph that
|
||||
/// would overrun either one fails, and the text renders nothing, as with the other budgets.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Limits {
|
||||
pub bytes: u64,
|
||||
pub work: u64,
|
||||
}
|
||||
|
||||
/// The colours a glyph paints with.
|
||||
pub(crate) struct Ink<'a> {
|
||||
pub palette: &'a [skrifa::color::Color],
|
||||
/// The text colour, straight sRGB and alpha.
|
||||
pub foreground: [f32; 4],
|
||||
}
|
||||
|
||||
/// A window-sized layer and how many clips were already pushed when it was opened: clips from
|
||||
/// outside a layer do not shape what is painted into it, they apply once when it is merged down.
|
||||
struct Layer {
|
||||
px: Vec<[f32; 4]>,
|
||||
clip_base: usize,
|
||||
}
|
||||
|
||||
/// Paints one colour glyph into a `w × h` window, premultiplied sRGB. `outline` adds a
|
||||
/// [`Shape`]'s coverage (through the given paint transform) to a window-sized [`Coverage`];
|
||||
/// `to_window` maps font units into the window (gradients).
|
||||
pub(crate) struct Painter<'a, F: FnMut(Shape, &Xform, &mut Coverage)> {
|
||||
w: usize,
|
||||
h: usize,
|
||||
outline: F,
|
||||
to_window: Xform,
|
||||
palette: &'a [skrifa::color::Color],
|
||||
/// The text colour, straight sRGB and alpha.
|
||||
foreground: [f32; 4],
|
||||
/// Anti-aliasing off: coverage is 0 or 1.
|
||||
hard: bool,
|
||||
ink: Ink<'a>,
|
||||
antialias: AntiAlias,
|
||||
limits: Limits,
|
||||
xforms: Vec<Xform>,
|
||||
clips: Vec<Vec<f32>>,
|
||||
/// Whether COLR clip boxes clip. Off for bent type: the outlines are bent but a clip box
|
||||
/// would stay a straight rectangle and cut them off. Gradients under a bend are laid out
|
||||
/// unbent either way.
|
||||
clip_boxes: bool,
|
||||
layers: Vec<Vec<[f32; 4]>>,
|
||||
/// `push_layer` calls past [`MAX_LAYERS`], painted into the layer below.
|
||||
flat: usize,
|
||||
/// A buffer could not be reserved: [`Painter::finish`] returns `None`.
|
||||
layers: Vec<Layer>,
|
||||
/// A buffer could not be reserved or a budget ran out: [`Painter::finish`] returns `None`.
|
||||
/// Placeholders (empty buffers) keep pushes and pops balanced and paint nothing.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
@@ -236,50 +302,100 @@ fn filled<T: Clone>(len: usize, v: T) -> Option<Vec<T>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl<'a, F: FnMut(GlyphId, &Xform, &mut Coverage)> Painter<'a, F> {
|
||||
pub fn new(w: usize, h: usize, outline: F, to_window: Xform, palette: &'a [skrifa::color::Color], foreground: [f32; 4], hard: bool) -> Self {
|
||||
let base = w.checked_mul(h).and_then(|n| filled(n, [0.0; 4]));
|
||||
let failed = base.is_none();
|
||||
let layers = base.into_iter().collect();
|
||||
Self { w, h, outline, to_window, palette, foreground, hard, xforms: Vec::new(), clips: Vec::new(), clip_boxes: true, layers, flat: 0, failed }
|
||||
impl<'a, F: FnMut(Shape, &Xform, &mut Coverage)> Painter<'a, F> {
|
||||
pub fn new(w: usize, h: usize, outline: F, to_window: Xform, ink: Ink<'a>, antialias: AntiAlias, limits: Limits) -> Self {
|
||||
let mut p = Self { w, h, outline, to_window, ink, antialias, limits, xforms: Vec::new(), clips: Vec::new(), layers: Vec::new(), failed: false };
|
||||
let base = p.new_layer().unwrap_or_default();
|
||||
p.layers.push(Layer { px: base, clip_base: 0 });
|
||||
p
|
||||
}
|
||||
|
||||
/// Leaves COLR clip boxes out (bent type); see [`Painter::clip_boxes`].
|
||||
pub fn with_clip_boxes(mut self, on: bool) -> Self {
|
||||
self.clip_boxes = on;
|
||||
self
|
||||
/// The pixel work left after this glyph; its buffers' bytes come back once it is done.
|
||||
pub fn work_left(&self) -> u64 {
|
||||
self.limits.work
|
||||
}
|
||||
|
||||
/// The painted glyph, premultiplied sRGB, row-major, or `None` when a buffer could not be
|
||||
/// reserved.
|
||||
/// reserved or a budget ran out.
|
||||
pub fn finish(mut self) -> Option<Vec<[f32; 4]>> {
|
||||
if self.failed {
|
||||
return None;
|
||||
}
|
||||
self.layers.truncate(1);
|
||||
self.layers.pop()
|
||||
self.layers.pop().map(|l| l.px)
|
||||
}
|
||||
|
||||
/// Records a buffer that could not be reserved; the empty clip keeps push/pop balanced and
|
||||
/// paints nothing.
|
||||
fn fail_clip(&mut self) {
|
||||
self.failed = true;
|
||||
self.clips.push(Vec::new());
|
||||
fn pixels(&self) -> Option<u64> {
|
||||
(self.w as u64).checked_mul(self.h as u64)
|
||||
}
|
||||
|
||||
/// Takes `bytes` of buffers and, with `work`, one window's worth of pixel work from the
|
||||
/// budgets; `false` (and the painter failed) when they don't fit or it failed already.
|
||||
fn spend(&mut self, bytes: Option<u64>, work: bool) -> bool {
|
||||
let work = if work { self.pixels() } else { Some(0) };
|
||||
match (bytes.and_then(|b| self.limits.bytes.checked_sub(b)), work.and_then(|n| self.limits.work.checked_sub(n))) {
|
||||
(Some(b), Some(w)) if !self.failed => {
|
||||
self.limits = Limits { bytes: b, work: w };
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
self.failed = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bytes of `len` elements of `each` bytes to the budget.
|
||||
fn give_back(&mut self, len: usize, each: usize) {
|
||||
let b = (len as u64).saturating_mul(each as u64);
|
||||
self.limits.bytes = self.limits.bytes.saturating_add(b);
|
||||
}
|
||||
|
||||
/// A transparent window-sized layer, its bytes taken from the budget.
|
||||
fn new_layer(&mut self) -> Option<Vec<[f32; 4]>> {
|
||||
let bytes = self.pixels().and_then(|n| n.checked_mul(size_of::<[f32; 4]>() as u64));
|
||||
if !self.spend(bytes, false) {
|
||||
return None;
|
||||
}
|
||||
let v = self.w.checked_mul(self.h).and_then(|n| filled(n, [0.0; 4]));
|
||||
self.failed |= v.is_none();
|
||||
v
|
||||
}
|
||||
|
||||
fn current(&self) -> Xform {
|
||||
self.xforms.last().copied().unwrap_or(Xform::IDENTITY)
|
||||
}
|
||||
|
||||
fn push_coverage(&mut self, cov: Coverage) {
|
||||
let Some(mut c) = cov.finish() else {
|
||||
self.fail_clip();
|
||||
/// The innermost clip pushed inside the current layer, if any.
|
||||
fn layer_clip(&self) -> Option<&Vec<f32>> {
|
||||
let base = self.layers.last().map_or(0, |l| l.clip_base);
|
||||
if self.clips.len() > base { self.clips.last() } else { None }
|
||||
}
|
||||
|
||||
/// Rasterises `shape` into a clip mask, intersected with the clip around it in this layer.
|
||||
fn push_shape(&mut self, shape: Shape) {
|
||||
// A mask is kept at one f32 per pixel; while it is built, the coverage accumulator and
|
||||
// the finished vector are live side by side.
|
||||
let mask = self.pixels().and_then(|n| n.checked_mul(size_of::<f32>() as u64));
|
||||
let cov = if self.spend(mask.and_then(|m| m.checked_mul(2)), true) { Coverage::try_new(self.w, self.h) } else { None };
|
||||
let Some(mut cov) = cov else {
|
||||
self.failed = true;
|
||||
self.clips.push(Vec::new());
|
||||
return;
|
||||
};
|
||||
if self.hard {
|
||||
c.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
|
||||
}
|
||||
if let Some(outer) = self.clips.last() {
|
||||
let m = self.current();
|
||||
(self.outline)(shape, &m, &mut cov);
|
||||
let c = cov.finish();
|
||||
// The accumulator is gone again: its half of the reservation comes back.
|
||||
self.give_back(self.w.saturating_mul(self.h), size_of::<f32>());
|
||||
let Some(mut c) = c else {
|
||||
self.failed = true;
|
||||
self.clips.push(Vec::new());
|
||||
return;
|
||||
};
|
||||
let mode = self.antialias;
|
||||
c.iter_mut().for_each(|v| *v = shape_antialias_coverage(*v, mode));
|
||||
if let Some(outer) = self.layer_clip() {
|
||||
c.iter_mut().zip(outer).for_each(|(v, o)| *v *= o);
|
||||
}
|
||||
self.clips.push(c);
|
||||
@@ -288,9 +404,9 @@ impl<'a, F: FnMut(GlyphId, &Xform, &mut Coverage)> Painter<'a, F> {
|
||||
/// A palette entry (or the text colour), premultiplied, times `alpha`.
|
||||
fn color(&self, index: u16, alpha: f32) -> [f32; 4] {
|
||||
let [r, g, b, a] = if index == FOREGROUND {
|
||||
self.foreground
|
||||
self.ink.foreground
|
||||
} else {
|
||||
match self.palette.get(usize::from(index)) {
|
||||
match self.ink.palette.get(usize::from(index)) {
|
||||
Some(c) => [c.red, c.green, c.blue, c.alpha].map(|v| f32::from(v) / 255.0),
|
||||
None => return [0.0; 4],
|
||||
}
|
||||
@@ -333,7 +449,7 @@ impl<'a, F: FnMut(GlyphId, &Xform, &mut Coverage)> Painter<'a, F> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: FnMut(GlyphId, &Xform, &mut Coverage)> ColorPainter for Painter<'_, F> {
|
||||
impl<F: FnMut(Shape, &Xform, &mut Coverage)> ColorPainter for Painter<'_, F> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
self.xforms.push(self.current().mul(&xform(&t)));
|
||||
}
|
||||
@@ -341,47 +457,27 @@ impl<F: FnMut(GlyphId, &Xform, &mut Coverage)> ColorPainter for Painter<'_, F> {
|
||||
self.xforms.pop();
|
||||
}
|
||||
fn push_clip_glyph(&mut self, id: GlyphId) {
|
||||
let Some(mut cov) = Coverage::try_new(self.w, self.h) else {
|
||||
self.fail_clip();
|
||||
return;
|
||||
};
|
||||
let m = self.current();
|
||||
(self.outline)(id, &m, &mut cov);
|
||||
self.push_coverage(cov);
|
||||
self.push_shape(Shape::Glyph(id));
|
||||
}
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
if !self.clip_boxes {
|
||||
// Keep push/pop balanced: the clip stays what it was.
|
||||
let same = match self.clips.last() {
|
||||
Some(c) => filled(c.len(), 0.0).map(|mut v| {
|
||||
v.copy_from_slice(c);
|
||||
v
|
||||
}),
|
||||
None => self.w.checked_mul(self.h).and_then(|n| filled(n, 1.0)),
|
||||
};
|
||||
match same {
|
||||
Some(same) => self.clips.push(same),
|
||||
None => self.fail_clip(),
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(mut cov) = Coverage::try_new(self.w, self.h) else {
|
||||
self.fail_clip();
|
||||
return;
|
||||
};
|
||||
rect_to(&mut cov, &self.to_window.mul(&self.current()), b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
self.push_coverage(cov);
|
||||
self.push_shape(Shape::Box(b));
|
||||
}
|
||||
fn pop_clip(&mut self) {
|
||||
self.clips.pop();
|
||||
if let Some(c) = self.clips.pop() {
|
||||
self.give_back(c.len(), size_of::<f32>());
|
||||
}
|
||||
}
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
if !self.spend(Some(0), true) {
|
||||
return;
|
||||
}
|
||||
let Some(paint) = self.paint(brush) else { return };
|
||||
let to_paint = self.to_window.mul(&self.current()).invert();
|
||||
let w = self.w;
|
||||
let clip = self.clips.last();
|
||||
let base = self.layers.last().map_or(0, |l| l.clip_base);
|
||||
let clip = if self.clips.len() > base { self.clips.last() } else { None };
|
||||
let Some(layer) = self.layers.last_mut() else { return };
|
||||
for (i, px) in layer.iter_mut().enumerate() {
|
||||
for (i, px) in layer.px.iter_mut().enumerate() {
|
||||
let k = clip.map_or(1.0, |c| c.get(i).copied().unwrap_or(0.0));
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
@@ -402,28 +498,30 @@ impl<F: FnMut(GlyphId, &Xform, &mut Coverage)> ColorPainter for Painter<'_, F> {
|
||||
}
|
||||
}
|
||||
fn push_layer(&mut self, _: CompositeMode) {
|
||||
if self.layers.len() >= MAX_LAYERS || self.flat > 0 {
|
||||
self.flat += 1;
|
||||
} else if let Some(layer) = self.w.checked_mul(self.h).and_then(|n| filled(n, [0.0; 4])) {
|
||||
self.layers.push(layer);
|
||||
} else {
|
||||
// Painted into the layer below, and the result is dropped by `finish`.
|
||||
self.failed = true;
|
||||
self.flat += 1;
|
||||
}
|
||||
// No fixed depth: skrifa stops at 64 nested paints, and the byte budget bounds the rest.
|
||||
let px = self.new_layer().unwrap_or_default();
|
||||
self.layers.push(Layer { px, clip_base: self.clips.len() });
|
||||
}
|
||||
fn pop_layer_with_mode(&mut self, mode: CompositeMode) {
|
||||
if self.flat > 0 {
|
||||
self.flat -= 1;
|
||||
return;
|
||||
}
|
||||
if self.layers.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(src) = self.layers.pop() else { return };
|
||||
self.give_back(src.px.len(), size_of::<[f32; 4]>());
|
||||
if !self.spend(Some(0), true) {
|
||||
return;
|
||||
}
|
||||
// The clips around the merged layer (inside the one below) apply once, to the result.
|
||||
let base = self.layers.last().map_or(0, |l| l.clip_base);
|
||||
let clip = if self.clips.len() > base { self.clips.last() } else { None };
|
||||
let Some(dst) = self.layers.last_mut() else { return };
|
||||
for (b, s) in dst.iter_mut().zip(&src) {
|
||||
*b = composite(mode, *b, *s);
|
||||
for (i, (b, s)) in dst.px.iter_mut().zip(&src.px).enumerate() {
|
||||
let k = clip.map_or(1.0, |c| c.get(i).copied().unwrap_or(0.0));
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = composite(mode, *b, *s);
|
||||
*b = std::array::from_fn(|j| b[j] + (c[j] - b[j]) * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,6 +529,7 @@ impl<F: FnMut(GlyphId, &Xform, &mut Coverage)> ColorPainter for Painter<'_, F> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::raster::rect_to;
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
@@ -467,45 +566,246 @@ mod tests {
|
||||
assert_eq!(wide.at(1.5), BLUE);
|
||||
}
|
||||
|
||||
const RED_PAL: [skrifa::color::Color; 2] =
|
||||
[skrifa::color::Color { red: 255, green: 0, blue: 0, alpha: 255 }, skrifa::color::Color { red: 0, green: 0, blue: 255, alpha: 255 }];
|
||||
const PLENTY: Limits = Limits { bytes: u64::MAX, work: u64::MAX };
|
||||
|
||||
fn ink() -> Ink<'static> {
|
||||
Ink { palette: &RED_PAL, foreground: [0.0, 0.0, 0.0, 1.0] }
|
||||
}
|
||||
|
||||
/// Draws every shape as given, glyph `n` as the rectangle 0..n x 0..8 (window pixels).
|
||||
fn shapes(shape: Shape, m: &Xform, c: &mut Coverage) {
|
||||
match shape {
|
||||
Shape::Glyph(id) => rect_to(c, m, 0.0, 0.0, f64::from(id.to_u32()) * 0.5, 8.0),
|
||||
Shape::Box(b) => rect_to(c, m, f64::from(b.x_min), f64::from(b.y_min), f64::from(b.x_max), f64::from(b.y_max)),
|
||||
}
|
||||
}
|
||||
|
||||
fn solid(index: u16) -> Brush<'static> {
|
||||
Brush::Solid { palette_index: index, alpha: 1.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_boxes_can_be_left_out_for_bent_type() {
|
||||
// Under a bending warp the outlines are bent but a clip box would stay a straight
|
||||
// rectangle and cut the bent glyph off, so bent type leaves clip boxes out.
|
||||
let palette = [skrifa::color::Color { red: 255, green: 0, blue: 0, alpha: 255 }];
|
||||
let paint = |clip_boxes: bool| {
|
||||
let full = |_: GlyphId, _: &Xform, c: &mut Coverage| rect_to(c, &Xform::IDENTITY, 0.0, 0.0, 8.0, 8.0);
|
||||
let mut p = Painter::new(8, 8, full, Xform::IDENTITY, &palette, [0.0, 0.0, 0.0, 1.0], false).with_clip_boxes(clip_boxes);
|
||||
p.push_clip_box(BoundingBox { x_min: 0.0, y_min: 0.0, x_max: 2.0, y_max: 2.0 });
|
||||
p.push_clip_glyph(GlyphId::new(1));
|
||||
p.fill(Brush::Solid { palette_index: 0, alpha: 1.0 });
|
||||
p.pop_clip();
|
||||
p.pop_clip();
|
||||
p.finish().unwrap_or_default()
|
||||
};
|
||||
let (clipped, bent) = (paint(true), paint(false));
|
||||
// Pixel (5, 5) lies outside the 2x2 clip box.
|
||||
assert_eq!(clipped.get(5 * 8 + 5).map(|c| c[3]), Some(0.0));
|
||||
assert_eq!(bent.get(5 * 8 + 5).map(|c| c[3]), Some(1.0));
|
||||
assert_eq!(bent.first().map(|c| c[3]), Some(1.0));
|
||||
fn clip_boxes_are_drawn_the_way_the_glyphs_are() {
|
||||
// Clip boxes go through the same drawing as the clip glyphs, so bent type bends its clip
|
||||
// boxes too instead of dropping them. A stand-in "bend" here shifts everything 4 px right.
|
||||
let bent = |shape: Shape, m: &Xform, c: &mut Coverage| shapes(shape, &Xform([1.0, 0.0, 0.0, 1.0, 4.0, 0.0]).mul(m), c);
|
||||
let mut p = Painter::new(8, 8, bent, Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
|
||||
p.push_clip_box(BoundingBox { x_min: 0.0, y_min: 0.0, x_max: 2.0, y_max: 2.0 });
|
||||
p.push_clip_glyph(GlyphId::new(16));
|
||||
p.fill(solid(0));
|
||||
p.pop_clip();
|
||||
p.pop_clip();
|
||||
let px = p.finish().unwrap_or_default();
|
||||
// The box moved with the shapes: 4..6 is painted, 0..2 and the rest are not.
|
||||
assert_eq!(px.get(8 + 5).map(|c| c[3]), Some(1.0));
|
||||
assert_eq!(px.get(8 + 1).map(|c| c[3]), Some(0.0));
|
||||
assert_eq!(px.get(5 * 8 + 5).map(|c| c[3]), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_too_large_to_reserve_paints_nothing() {
|
||||
// Coverage buffers are fallible on main (#4979); a colour glyph whose window cannot be
|
||||
// reserved gives `None` instead of aborting, and its clips stay balanced.
|
||||
let palette = [skrifa::color::Color { red: 255, green: 0, blue: 0, alpha: 255 }];
|
||||
let full = |_: GlyphId, _: &Xform, c: &mut Coverage| rect_to(c, &Xform::IDENTITY, 0.0, 0.0, 8.0, 8.0);
|
||||
let mut p = Painter::new(usize::MAX / 2, 4, full, Xform::IDENTITY, &palette, [0.0, 0.0, 0.0, 1.0], false);
|
||||
let mut p = Painter::new(usize::MAX / 2, 4, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
|
||||
p.push_clip_box(BoundingBox { x_min: 0.0, y_min: 0.0, x_max: 2.0, y_max: 2.0 });
|
||||
p.push_clip_glyph(GlyphId::new(1));
|
||||
p.push_layer(CompositeMode::SrcOver);
|
||||
p.fill(Brush::Solid { palette_index: 0, alpha: 1.0 });
|
||||
p.fill(solid(0));
|
||||
p.pop_layer_with_mode(CompositeMode::SrcOver);
|
||||
p.pop_clip();
|
||||
p.pop_clip();
|
||||
assert!(p.finish().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_clips_count_against_the_byte_budget() {
|
||||
// An 8x8 window: 1 KiB per layer, 256 bytes per clip mask (512 while it is built).
|
||||
let run = |bytes: u64| {
|
||||
let mut p = Painter::new(8, 8, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, Limits { bytes, work: u64::MAX });
|
||||
p.push_clip_glyph(GlyphId::new(16));
|
||||
for _ in 0..3 {
|
||||
p.push_layer(CompositeMode::SrcOver);
|
||||
}
|
||||
p.fill(solid(0));
|
||||
for _ in 0..3 {
|
||||
p.pop_layer_with_mode(CompositeMode::SrcOver);
|
||||
}
|
||||
p.pop_clip();
|
||||
p.finish()
|
||||
};
|
||||
// Base layer, mask and three layers live at once.
|
||||
assert!(run(4 * 1024 + 512).is_some());
|
||||
assert!(run(4 * 1024 + 255).is_none());
|
||||
// Buffers come back when popped: one more layer later on fits in the same budget.
|
||||
let mut p = Painter::new(8, 8, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, Limits { bytes: 2 * 1024, work: u64::MAX });
|
||||
for _ in 0..5 {
|
||||
p.push_layer(CompositeMode::SrcOver);
|
||||
p.fill(solid(0));
|
||||
p.pop_layer_with_mode(CompositeMode::SrcOver);
|
||||
}
|
||||
assert!(p.finish().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_glyph_work_is_capped() {
|
||||
// Each fill, clip and merge touches the whole 8x8 window (64 px of work).
|
||||
let run = |work: u64| {
|
||||
let mut p = Painter::new(8, 8, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, Limits { bytes: u64::MAX, work });
|
||||
for _ in 0..3 {
|
||||
p.fill(solid(0));
|
||||
}
|
||||
let left = p.work_left();
|
||||
(p.finish().is_some(), left)
|
||||
};
|
||||
assert_eq!(run(3 * 64), (true, 0));
|
||||
assert!(!run(3 * 64 - 1).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_composites_keep_their_modes() {
|
||||
// Past what used to be an eight-layer cap, a nested Clear still clears: the red fill
|
||||
// underneath must not show.
|
||||
let mut p = Painter::new(4, 4, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
|
||||
for _ in 0..9 {
|
||||
p.push_layer(CompositeMode::SrcOver);
|
||||
}
|
||||
p.fill(solid(0));
|
||||
p.push_layer(CompositeMode::Clear);
|
||||
p.fill(solid(1));
|
||||
p.pop_layer_with_mode(CompositeMode::Clear);
|
||||
for _ in 0..9 {
|
||||
p.pop_layer_with_mode(CompositeMode::SrcOver);
|
||||
}
|
||||
let px = p.finish().unwrap_or_default();
|
||||
assert_eq!(px.len(), 16);
|
||||
assert!(px.iter().all(|c| c[3] == 0.0), "{:?}", px.first());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_clip_applies_once_to_a_composite_inside_it() {
|
||||
// A PaintGlyph around a SrcIn composite: the edge pixel half covered by the glyph ends
|
||||
// up half opaque, not a quarter (the clip on both the backdrop and the source).
|
||||
let mut p = Painter::new(
|
||||
2,
|
||||
1,
|
||||
|s: Shape, m: &Xform, c: &mut Coverage| {
|
||||
if let Shape::Glyph(_) = s {
|
||||
rect_to(c, m, 0.0, 0.0, 1.5, 1.0);
|
||||
}
|
||||
},
|
||||
Xform::IDENTITY,
|
||||
ink(),
|
||||
AntiAlias::Smooth,
|
||||
PLENTY,
|
||||
);
|
||||
p.push_clip_glyph(GlyphId::new(1));
|
||||
p.push_layer(CompositeMode::SrcOver);
|
||||
p.fill(solid(0));
|
||||
p.push_layer(CompositeMode::SrcIn);
|
||||
p.fill(solid(1));
|
||||
p.pop_layer_with_mode(CompositeMode::SrcIn);
|
||||
p.pop_layer_with_mode(CompositeMode::SrcOver);
|
||||
p.pop_clip();
|
||||
let px = p.finish().unwrap_or_default();
|
||||
assert_eq!(px.first().map(|c| c[3]), Some(1.0));
|
||||
let edge = px.get(1).copied().unwrap_or_default();
|
||||
assert!((edge[3] - 0.5).abs() < 1e-5 && (edge[2] - 0.5).abs() < 1e-5, "{edge:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_composite_leaves_pixels_outside_its_clip_alone() {
|
||||
// Red everywhere, then a Src composite of blue inside a clip over the left pixel only:
|
||||
// Src replaces what is below, but only where the clip lets it.
|
||||
let mut p = Painter::new(
|
||||
2,
|
||||
1,
|
||||
|s: Shape, m: &Xform, c: &mut Coverage| {
|
||||
if let Shape::Glyph(_) = s {
|
||||
rect_to(c, m, 0.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
},
|
||||
Xform::IDENTITY,
|
||||
ink(),
|
||||
AntiAlias::Smooth,
|
||||
PLENTY,
|
||||
);
|
||||
p.fill(solid(0));
|
||||
p.push_clip_glyph(GlyphId::new(1));
|
||||
p.push_layer(CompositeMode::Src);
|
||||
p.fill(solid(1));
|
||||
p.pop_layer_with_mode(CompositeMode::Src);
|
||||
p.pop_clip();
|
||||
let px = p.finish().unwrap_or_default();
|
||||
assert_eq!(px, vec![BLUE, RED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_glyph_edges_follow_the_anti_alias_mode() {
|
||||
// A clip covering 0.5 of a pixel: Smooth keeps 0.5, Sharp thins it as the plain passes do.
|
||||
let paint = |mode| {
|
||||
let mut p = Painter::new(1, 1, |_: Shape, m: &Xform, c: &mut Coverage| rect_to(c, m, 0.0, 0.0, 0.5, 1.0), Xform::IDENTITY, ink(), mode, PLENTY);
|
||||
p.push_clip_glyph(GlyphId::new(1));
|
||||
p.fill(solid(0));
|
||||
p.pop_clip();
|
||||
p.finish().unwrap_or_default().first().map_or(0.0, |c| c[3])
|
||||
};
|
||||
assert!((paint(AntiAlias::Smooth) - 0.5).abs() < 1e-5);
|
||||
assert!((paint(AntiAlias::Sharp) - shape_antialias_coverage(0.5, AntiAlias::Sharp)).abs() < 1e-5);
|
||||
assert!(paint(AntiAlias::Sharp) < 0.3);
|
||||
assert_eq!(paint(AntiAlias::None), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_bounds_take_the_intersection_of_the_clips() {
|
||||
// A small glyph inside a huge clip box: the bounds are the glyph's, not the box's.
|
||||
let mut b = Bounds::default();
|
||||
let to = |s: Shape, m: &Xform, b: &mut Bounds| match s {
|
||||
Shape::Glyph(_) => rect_to(b, m, 0.0, 0.0, 8.0, 8.0),
|
||||
Shape::Box(r) => rect_to(b, m, f64::from(r.x_min), f64::from(r.y_min), f64::from(r.x_max), f64::from(r.y_max)),
|
||||
};
|
||||
let mut o = OutlineBounds::new(&mut b, to);
|
||||
o.push_clip_box(BoundingBox { x_min: -1e6, y_min: -1e6, x_max: 1e6, y_max: 1e6 });
|
||||
o.push_clip_glyph(GlyphId::new(1));
|
||||
o.fill(solid(0));
|
||||
o.pop_clip();
|
||||
// Clips that don't overlap paint nothing.
|
||||
o.push_clip_box(BoundingBox { x_min: 100.0, y_min: 100.0, x_max: 200.0, y_max: 200.0 });
|
||||
o.push_clip_glyph(GlyphId::new(1));
|
||||
o.fill(solid(0));
|
||||
o.pop_clip();
|
||||
o.pop_clip();
|
||||
o.pop_clip();
|
||||
assert_eq!(b.rect, Some([0.0, 0.0, 8.0, 8.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_stops_switch_at_their_offset() {
|
||||
// A hard red-to-blue step at 0.5: exactly at 0.5 the colour after the step holds.
|
||||
let step = ColorLine { stops: vec![(0.0, RED), (0.5, RED), (0.5, BLUE), (1.0, BLUE)], extend: Extend::Pad };
|
||||
assert_eq!(step.at(0.5), BLUE);
|
||||
assert_eq!(step.at(0.499), RED);
|
||||
assert_eq!(step.at(0.0), RED);
|
||||
// Several stops at the very start: the last of them holds from there.
|
||||
let start = ColorLine { stops: vec![(0.0, RED), (0.0, BLUE), (1.0, BLUE)], extend: Extend::Pad };
|
||||
assert_eq!(start.at(0.0), BLUE);
|
||||
assert_eq!(start.at(-1.0), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_angles_past_360_reach_the_positive_x_axis() {
|
||||
// skrifa's 270°..450° (clockwise) crosses the positive x axis at 360°, the midpoint.
|
||||
let p = Paint::Sweep { c: (0.0, 0.0), start: 270.0, end: 450.0, line: line(Extend::Pad) };
|
||||
let mid = p.at(1.0, 0.0).unwrap_or_default();
|
||||
assert!((mid[0] - 0.5).abs() < 1e-4 && (mid[2] - 0.5).abs() < 1e-4, "{mid:?}");
|
||||
// A sweep starting below 0°: 350° is -10°, a sixth of the way through -30°..30°.
|
||||
let p = Paint::Sweep { c: (0.0, 0.0), start: -30.0, end: 30.0, line: line(Extend::Pad) };
|
||||
let ccw10 = 10f32.to_radians();
|
||||
let c = p.at(ccw10.cos(), ccw10.sin()).unwrap_or_default();
|
||||
assert!((c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_gradient_is_the_two_point_cone() {
|
||||
// Concentric circles, radius 0 to 10: t is the distance over 10, red at the centre.
|
||||
|
||||
@@ -236,3 +236,72 @@ fn colr_v1_gradient_runs_across_the_glyph() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A COLR v1 table whose `base` paints itself (PaintColrGlyph), a cycle skrifa refuses to walk.
|
||||
fn colr_v1_cycle(base: u16) -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
for v in [1u16, 0] {
|
||||
t.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
t.extend_from_slice(&0u32.to_be_bytes());
|
||||
t.extend_from_slice(&0u32.to_be_bytes());
|
||||
t.extend_from_slice(&0u16.to_be_bytes());
|
||||
t.extend_from_slice(&34u32.to_be_bytes());
|
||||
for _ in 0..4 {
|
||||
t.extend_from_slice(&0u32.to_be_bytes());
|
||||
}
|
||||
t.extend_from_slice(&1u32.to_be_bytes());
|
||||
t.extend_from_slice(&base.to_be_bytes());
|
||||
t.extend_from_slice(&10u32.to_be_bytes());
|
||||
// PaintColrGlyph (format 11) of itself.
|
||||
t.push(11);
|
||||
t.extend_from_slice(&base.to_be_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_glyph_skrifa_cannot_walk_draws_its_outline() {
|
||||
// A paint graph that loops back on itself fails in skrifa; the glyph keeps its plain
|
||||
// outline in the text colour instead of vanishing.
|
||||
let inter = fonts::INTER_REGULAR.as_slice();
|
||||
let h = gid(inter, 'H');
|
||||
let font = with_tables(inter, &[(*b"COLR", colr_v1_cycle(h)), (*b"CPAL", cpal(&[RED]))]);
|
||||
let green = Color::rgb(0.0, 1.0, 0.0);
|
||||
let px = pixels(&layout_with("H", font, green), PixelFormat::RGBA8);
|
||||
let solid: Vec<_> = px.iter().filter(|p| p[3] > 0.99).collect();
|
||||
assert!(solid.len() > 100, "{} pixels", solid.len());
|
||||
assert!(solid.iter().all(|p| p[1] > 0.98 && p[0] < 0.02), "{:?}", solid.first());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_glyph_without_a_base_outline_still_paints() {
|
||||
// The base glyph has no outline of its own (no glyf entry); its colour layer does.
|
||||
let inter = fonts::INTER_REGULAR.as_slice();
|
||||
let h = gid(inter, 'H');
|
||||
let base = 60000u16;
|
||||
let font = with_tables(inter, &[(*b"COLR", colr_v0(base, &[(h, 0)])), (*b"CPAL", cpal(&[RED]))]);
|
||||
assert!(FontRef::new(&font).is_ok_and(|f| f.outline_glyphs().get(skrifa::GlyphId::new(base.into())).is_none()));
|
||||
let mut l = layout_with("H", font, Color::BLACK);
|
||||
for g in &mut l.glyphs {
|
||||
g.id = base.into();
|
||||
}
|
||||
let px = pixels(&l, PixelFormat::RGBA8);
|
||||
let red = px.iter().filter(|p| p[3] > 0.99 && p[0] > 0.98 && p[1] < 0.02).count();
|
||||
assert!(red > 100, "{red} red pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_glyphs_keep_extended_text_colours_in_float_documents() {
|
||||
// A layer in the text colour (palette entry 0xFFFF) keeps HDR and signed components in a
|
||||
// float document, as plain glyphs do, and stays bounded in integer ones.
|
||||
let inter = fonts::INTER_REGULAR.as_slice();
|
||||
let h = gid(inter, 'H');
|
||||
let font = with_tables(inter, &[(*b"COLR", colr_v0(h, &[(h, 0xFFFF)])), (*b"CPAL", cpal(&[RED]))]);
|
||||
let color = Color::rgba(2.5, -0.25, 0.5, 1.0);
|
||||
let float = pixels(&layout_with("H", font.clone(), color), PixelFormat::RGBA32F);
|
||||
let covered: Vec<_> = float.iter().filter(|p| p[3] > 0.01).collect();
|
||||
assert!(covered.len() > 100);
|
||||
assert!(covered.iter().all(|p| (p[0] - 2.5).abs() < 1e-4 && (p[1] + 0.25).abs() < 1e-4), "{:?}", covered.first());
|
||||
let int = pixels(&layout_with("H", font, color), PixelFormat::RGBA8);
|
||||
assert!(int.iter().filter(|p| p[3] > 0.01).all(|p| p[0] == 1.0 && p[1] == 0.0));
|
||||
}
|
||||
+62
-28
@@ -8,7 +8,7 @@ use skrifa::instance::{LocationRef, NormalizedCoord, Size};
|
||||
use skrifa::outline::DrawSettings;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
use crate::color_glyphs::{OutlineBounds, Painter};
|
||||
use crate::color_glyphs::{Ink, Limits, NoPaint, OutlineBounds, Painter, Shape};
|
||||
use crate::layout::{DecorationRect, GlyphOrient, PlacedGlyph, TextLayout};
|
||||
use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to};
|
||||
use crate::warp::{Placement, Warp};
|
||||
@@ -62,7 +62,7 @@ fn nothing(format: PixelFormat) -> Rendered {
|
||||
|
||||
/// Maps raw area-coverage to the opacity Photoshop's anti-alias modes imply. Exponents above 1 thin
|
||||
/// the fringe (sharper); below 1 widen it (stronger smoothing).
|
||||
fn shape_antialias_coverage(cv: f32, mode: AntiAlias) -> f32 {
|
||||
pub(crate) fn shape_antialias_coverage(cv: f32, mode: AntiAlias) -> f32 {
|
||||
if !cv.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -124,14 +124,15 @@ fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) ->
|
||||
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
|
||||
return false;
|
||||
};
|
||||
if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() {
|
||||
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
// A colour glyph paints its layers, so it counts even when its base glyph has no outline.
|
||||
if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() && color_glyph(&font, GlyphId::new(g.id), &coords).is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(w) = warp {
|
||||
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
if matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden) {
|
||||
return false;
|
||||
}
|
||||
if let Some(w) = warp
|
||||
&& matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -201,13 +202,17 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only:
|
||||
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
// Colour glyphs paint in their own colours ([`paint_color_glyphs`]); only their extent
|
||||
// counts here.
|
||||
if let Some(color) = font.color_glyphs().get(GlyphId::new(g.id)) {
|
||||
if let Some(color) = color_glyph(&font, GlyphId::new(g.id), &coords) {
|
||||
if only.is_none()
|
||||
&& let Some((glyph, bend)) = place(layout, g, &font, face.size_px, &coords, warp)
|
||||
{
|
||||
let unit = font_units(&font, face.size_px, &coords, glyph);
|
||||
let outline = |id: GlyphId, m: &Xform, s: &mut _| outline_into(&font, &coords, id, unit.mul(m), transform, bend, s);
|
||||
let _ = color.paint(LocationRef::new(&coords), &mut OutlineBounds::new(&mut *sink, outline, transform.mul(&unit)));
|
||||
let outline = |shape: Shape, m: &Xform, s: &mut Bounds| shape_into(&font, &coords, shape, unit.mul(m), transform, bend, s);
|
||||
let mut b = Bounds::default();
|
||||
let _ = color.paint(LocationRef::new(&coords), &mut OutlineBounds::new(&mut b, outline));
|
||||
if let Some([x0, y0, x1, y1]) = b.rect {
|
||||
rect_to(sink, &Xform::IDENTITY, x0, y0, x1, y1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -308,26 +313,47 @@ fn draw_outline(
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph `id`'s unscaled outline through `local` (font units → text space) into `sink`, for
|
||||
/// colour glyph layers.
|
||||
fn outline_into(
|
||||
/// The colour glyph `id` paints as, when the font has one and skrifa can walk its paint graph.
|
||||
/// A graph it cannot walk (too deep, a cycle, a broken offset) leaves the glyph to its plain
|
||||
/// outline in the text colour instead of dropping it.
|
||||
fn color_glyph<'a>(font: &skrifa::FontRef<'a>, id: GlyphId, coords: &[NormalizedCoord]) -> Option<skrifa::color::ColorGlyph<'a>> {
|
||||
let color = font.color_glyphs().get(id)?;
|
||||
color.paint(LocationRef::new(coords), &mut NoPaint).is_ok().then_some(color)
|
||||
}
|
||||
|
||||
/// A colour glyph's clip [`Shape`], unscaled font units through `local` (font units → text
|
||||
/// space), into `sink`: a glyph's outline, or a clip box, bent by `bend` like the outlines so it
|
||||
/// still crops what it was meant to.
|
||||
fn shape_into(
|
||||
font: &skrifa::FontRef,
|
||||
coords: &[NormalizedCoord],
|
||||
id: GlyphId,
|
||||
shape: Shape,
|
||||
local: Xform,
|
||||
transform: &Xform,
|
||||
bend: Option<&Warp>,
|
||||
sink: &mut impl LineSink,
|
||||
) {
|
||||
if let Some(outline) = font.outline_glyphs().get(id) {
|
||||
draw_outline(&outline, DrawSettings::unhinted(Size::unscaled(), LocationRef::new(coords)), local, transform, bend, sink);
|
||||
match shape {
|
||||
Shape::Glyph(id) => {
|
||||
if let Some(outline) = font.outline_glyphs().get(id) {
|
||||
draw_outline(&outline, DrawSettings::unhinted(Size::unscaled(), LocationRef::new(coords)), local, transform, bend, sink);
|
||||
}
|
||||
}
|
||||
Shape::Box(b) => {
|
||||
let (x0, y0, x1, y1) = (f64::from(b.x_min), f64::from(b.y_min), f64::from(b.x_max), f64::from(b.y_max));
|
||||
match bend {
|
||||
Some(w) => rect_to(&mut WarpSink { inner: sink, warp: w, post: *transform }, &local, x0, y0, x1, y1),
|
||||
None => rect_to(sink, &transform.mul(&local), x0, y0, x1, y1),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the colour glyphs of `layout` in their own colours over the premultiplied accumulator
|
||||
/// (`w × h`, `format`'s colour channels then alpha): their palette's sRGB converted to the
|
||||
/// document's colour model, times the text colour's opacity. `None` when a glyph's buffers could
|
||||
/// not be reserved.
|
||||
/// not be reserved or `limits` ran out (see [`Limits`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_color_glyphs(
|
||||
layout: &TextLayout,
|
||||
xf: &Xform,
|
||||
@@ -336,21 +362,24 @@ fn paint_color_glyphs(
|
||||
antialias: AntiAlias,
|
||||
acc: &mut [f32],
|
||||
(w, h): (usize, usize),
|
||||
mut limits: Limits,
|
||||
) -> Option<()> {
|
||||
let n = format.mode.color_channels();
|
||||
let stride = n + 1;
|
||||
let opaque = PixelFormat { alpha: false, ..*format };
|
||||
// Float documents keep extended (HDR, signed) colours, as the plain passes do.
|
||||
let keep_range = |v: f32| if format.sample == SampleType::F32 && v.is_finite() { v } else { v.clamp(0.0, 1.0) };
|
||||
for g in &layout.glyphs {
|
||||
let (Some(st), Some(face)) = (layout.styles.get(g.style as usize), layout.faces.get(g.face as usize)) else { continue };
|
||||
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { continue };
|
||||
let Some(color) = font.color_glyphs().get(GlyphId::new(g.id)) else { continue };
|
||||
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
let Some(color) = color_glyph(&font, GlyphId::new(g.id), &coords) else { continue };
|
||||
let Some((glyph, bend)) = place(layout, g, &font, face.size_px, &coords, warp) else { continue };
|
||||
let unit = font_units(&font, face.size_px, &coords, glyph);
|
||||
// The window the glyph can paint: its layers' outlines and clip boxes.
|
||||
// The window the glyph can paint: its fills within their clips.
|
||||
let mut b = Bounds::default();
|
||||
let outline = |id: GlyphId, m: &Xform, s: &mut Bounds| outline_into(&font, &coords, id, unit.mul(m), xf, bend, s);
|
||||
if color.paint(LocationRef::new(&coords), &mut OutlineBounds::new(&mut b, outline, xf.mul(&unit))).is_err() {
|
||||
let outline = |shape: Shape, m: &Xform, s: &mut Bounds| shape_into(&font, &coords, shape, unit.mul(m), xf, bend, s);
|
||||
if color.paint(LocationRef::new(&coords), &mut OutlineBounds::new(&mut b, outline)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some([x0, y0, x1, y1]) = b.rect else { continue };
|
||||
@@ -364,19 +393,20 @@ fn paint_color_glyphs(
|
||||
let palettes = font.color_palettes();
|
||||
let palette = palettes.get(0);
|
||||
let [r, gr, bl] = st.color.to_rgb();
|
||||
let outline = |id: GlyphId, m: &Xform, s: &mut Coverage| outline_into(&font, &coords, id, unit.mul(m), &to_window, bend, s);
|
||||
let colors = palette.as_ref().map_or(&[][..], |p| p.colors());
|
||||
let mut painter =
|
||||
Painter::new(ww, wh, outline, to_window.mul(&unit), colors, [r, gr, bl, 1.0], antialias == AntiAlias::None).with_clip_boxes(bend.is_none());
|
||||
let outline = |shape: Shape, m: &Xform, s: &mut Coverage| shape_into(&font, &coords, shape, unit.mul(m), &to_window, bend, s);
|
||||
let ink = Ink { palette: palette.as_ref().map_or(&[][..], |p| p.colors()), foreground: [r, gr, bl, 1.0] };
|
||||
let mut painter = Painter::new(ww, wh, outline, to_window.mul(&unit), ink, antialias, limits);
|
||||
if color.paint(LocationRef::new(&coords), &mut painter).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Compositing the window into the text is one more pass over it.
|
||||
limits.work = painter.work_left().checked_sub((ww as u64).checked_mul(wh as u64)?)?;
|
||||
let opacity = st.color.alpha.clamp(0.0, 1.0);
|
||||
for (i, s) in painter.finish()?.iter().enumerate() {
|
||||
let a = s[3] * opacity;
|
||||
let o = ((wy0 + i / ww) * w + wx0 + i % ww) * stride;
|
||||
let (true, Some(px)) = (a > 0.0, acc.get_mut(o..o + stride)) else { continue };
|
||||
let rgb = [s[0] / s[3], s[1] / s[3], s[2] / s[3]].map(|v| v.clamp(0.0, 1.0));
|
||||
let rgb = [s[0] / s[3], s[1] / s[3], s[2] / s[3]].map(keep_range);
|
||||
let comps = if format.mode == ColorMode::Rgb { rgb.to_vec() } else { photocraft_raster::from_rgba(&opaque, [rgb[0], rgb[1], rgb[2], 1.0]) };
|
||||
let keep = 1.0 - a;
|
||||
for (p, c) in px.iter_mut().zip(&comps).take(n) {
|
||||
@@ -575,7 +605,11 @@ pub(crate) fn rasterize_passes(
|
||||
let a = c.alpha.clamp(0.0, 1.0);
|
||||
composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias);
|
||||
}
|
||||
if paint_color_glyphs(layout, &xf, warp, &format, antialias, &mut acc, (w, h)).is_none() {
|
||||
// Colour glyphs get what the plain passes left of both budgets: the bytes beside the
|
||||
// accumulator and the output surface ([`fits_raster_budget`]), and the pixel work.
|
||||
let deep = (w as u64).saturating_mul(h as u64).saturating_mul(stride as u64).saturating_mul(size_of::<f32>() as u64 + format.sample.bytes() as u64);
|
||||
let limits = Limits { bytes: MAX_RASTER_BYTES.saturating_sub(deep), work: MAX_PASS_PIXELS.saturating_sub(pass_pixels) };
|
||||
if paint_color_glyphs(layout, &xf, warp, &format, antialias, &mut acc, (w, h), limits).is_none() {
|
||||
return nothing(format);
|
||||
}
|
||||
// Unpremultiply.
|
||||
|
||||
Reference in new issue
Block a user