Character: Crisp and Strong reshape the edges of each of the type's paints on its own

Crisp and Strong scaled each edge pixel of the composited type by the
share the method gives the characters' coverage. Where translucent
paints overlap, that drew the edges too opaque: two 50% fills at half
coverage came out at 0.875 with Strong instead of 0.75.

Each of the object's own fills and strokes, and each fill and stroke of
a run, is now drawn offscreen on its own and takes the method from its
own coverage, and the results are composited in paint order. Fills and
strokes of consecutive runs in solid colors are drawn together while the
colors are opaque. Type with more than eight such groups is drawn as one
group, as before. A pixel that every group covers wholly or not at all
keeps the pixel Sharp draws, so faint fills composite inside the
characters before their colors are rounded.
This commit is contained in:
dotCooCoo
2026-10-31 19:51:22 -07:00
parent 9bb38208c1
commit faf62c515d
2 changed files with 182 additions and 14 deletions
+119 -14
View File
@@ -211,11 +211,13 @@ impl Renderer {
/// Draw type `t` of node `n` (laid out in `g`) with its anti-aliasing method, None, Crisp or
/// Strong, on the pixels of the output ([`Frame::output`]): the type is drawn offscreen on those
/// pixels and placed back pixel for pixel. With None it is drawn with hard edges, so a pixel
/// takes the type's color where the type covers at least half of it. With Crisp and Strong it
/// is drawn as Sharp draws it, and again with the characters and the object's own fills and
/// strokes in opaque black, which gives the share `c` of each pixel they cover; the pixel's
/// color and opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity
/// of the paints is kept. Where the output's pixels would number more than the frame's there
/// takes the type's color where the type covers at least half of it. With Crisp and Strong each
/// group of its paints ([`type_paints`]: the object's own fills and strokes one at a time, and
/// the characters' fills and strokes) is drawn on its own as Sharp draws it, and again in
/// opaque black, which gives the share `c` of each pixel the group covers; the pixel's color and
/// opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity of the
/// paints is kept, and the groups are composited in the order they are drawn. A pixel that
/// every group covers wholly or not at all is the pixel Sharp draws. Where the output's pixels would number more than the frame's there
/// and more than [`fx::PIXEL_BUDGET`], or be more than [`MAX_RASTER_SIDE`] a side (a frame
/// drawn coarser than the output), the frame's own pixels are used.
///
@@ -364,19 +366,27 @@ impl Renderer {
pm
};
let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() };
let mut pm = draw(self, frame, n, &sharp);
if t.anti_alias != TypeAntiAlias::None {
let black = |p: &mut vectorcraft_color::Paint| {
if !p.is_none() {
*p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
}
if t.anti_alias == TypeAntiAlias::None {
return draw(self, frame, n, &sharp);
}
let black = |p: &mut vectorcraft_color::Paint| {
if !p.is_none() {
*p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
}
};
// A group of paints drawn on its own, its pixels taking the method from its own coverage,
// and that coverage.
let reshaped = |r: &mut Self, group: Option<&[TypePaint]>| {
let (pn, pt) = match group {
Some(keep) => type_paint_only(n, &sharp, keep),
None => (n.clone(), sharp.clone()),
};
let mut ink = sharp;
let mut pm = draw(r, frame, &pn, &pt);
let (mut inked, mut ink) = (pn, pt);
for r in &mut ink.runs {
black(&mut r.style.fill);
black(&mut r.style.stroke);
}
let mut inked = n.clone();
for item in &mut inked.appearance.items {
black(item.paint_mut());
match item {
@@ -384,10 +394,39 @@ impl Renderer {
AppearanceItem::Stroke(st) => st.opacity = 1.0,
}
}
let covered = draw(self, frame, &inked, &ink);
let covered = draw(r, frame, &inked, &ink);
for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) {
*p = edge_pixel(*p, c.a, t.anti_alias);
}
(pm, covered)
};
let groups: Vec<Option<Vec<TypePaint>>> = match type_paints(n, &sharp, g, self.alpha >= 1.0) {
Some(groups) => groups.into_iter().map(Some).collect(),
None => vec![None],
};
if groups.len() <= 1 {
return groups.first().map_or_else(|| Pixmap::new(w, h), |group| reshaped(self, group.as_deref()).0);
}
// Where every group covers a pixel wholly or not at all, the pixel is the type's as Sharp
// draws it. Elsewhere it is the groups composited in the order they are drawn, source over.
let mut pm = draw(self, frame, n, &sharp);
let mut sum = vec![[0.0f32; 4]; usize::from(w) * usize::from(h)];
let mut edge = vec![false; sum.len()];
for group in &groups {
let (layer, covered) = reshaped(self, group.as_deref());
for (((s, e), p), c) in sum.iter_mut().zip(&mut edge).zip(layer.data()).zip(covered.data()) {
*e |= c.a != 0 && c.a != 255;
let keep = 1.0 - f32::from(p.a) / 255.0;
for (v, c) in s.iter_mut().zip([p.r, p.g, p.b, p.a]) {
*v = f32::from(c) + *v * keep;
}
}
}
for ((p, s), e) in pm.data_mut().iter_mut().zip(&sum).zip(&edge) {
if *e {
let [red, green, blue, a] = s.map(|v| v.round().clamp(0.0, 255.0) as u8);
*p = vello_cpu::color::PremulRgba8 { r: red.min(a), g: green.min(a), b: blue.min(a), a };
}
}
pm
}
@@ -497,6 +536,72 @@ pub(crate) fn edge_grid(on: Rect, grid: Affine) -> Affine {
if area(out) > area(on).max(fx::PIXEL_BUDGET) || out.width() > side || out.height() > side { Affine::IDENTITY } else { grid }
}
/// One paint of type: one of the object's own fills and strokes (its index), or the fill or the
/// stroke of a run (its index).
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum TypePaint {
Item(usize),
Fill(usize),
Stroke(usize),
}
/// Most groups of paints [`Renderer::draw_type_pixels`] draws one at a time ([`type_paints`]).
const MAX_TYPE_PAINTS: usize = 8;
/// The paints of type `t` of node `n` (laid out in `g`) in groups that
/// [`Renderer::draw_type_pixels`] draws one at a time, in the order they are drawn: the object's
/// own fills and strokes below the characters, the runs' fills and strokes, then the object's own
/// fills and strokes above the characters. Each of the object's own fills and strokes is a group,
/// and so is each fill and stroke of a run, except that fills and strokes of runs one after
/// another in solid colors are one group when colors are drawn opaque (`opaque`). Paints that
/// draw nothing are left out. `None` for type with more than [`MAX_TYPE_PAINTS`] groups, which is
/// drawn as one.
fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option<Vec<Vec<TypePaint>>> {
let shown = |i: &AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(st) if st.width <= 0.0);
let items = &n.appearance.items;
let k = n.appearance.contents_at();
let mut groups: Vec<Vec<TypePaint>> = (0..k).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)]).collect();
// Whether the last group holds run paints in opaque solid colors, which the next such paint joins.
let mut joins = false;
for (i, run) in t.runs.iter().enumerate() {
if g.run(i).elements().is_empty() {
continue;
}
let fill = (!run.style.fill.is_none()).then_some((TypePaint::Fill(i), &run.style.fill));
let stroke = run.style.has_stroke().then_some((TypePaint::Stroke(i), &run.style.stroke));
for (p, paint) in [fill, stroke].into_iter().flatten() {
let solid = opaque && matches!(paint, vectorcraft_color::Paint::Solid { .. });
match groups.last_mut() {
Some(last) if joins && solid => last.push(p),
_ => groups.push(vec![p]),
}
joins = solid;
}
}
groups.extend((k..items.len()).filter(|j| shown(&items[*j])).map(|j| vec![TypePaint::Item(j)]));
(groups.len() <= MAX_TYPE_PAINTS).then_some(groups)
}
/// Node `n` and type `t` with every paint but those in `keep` taken away.
fn type_paint_only(n: &Node, t: &TextObject, keep: &[TypePaint]) -> (Node, TextObject) {
let keep: std::collections::HashSet<TypePaint> = keep.iter().copied().collect();
let (mut n, mut t) = (n.clone(), t.clone());
for (j, item) in n.appearance.items.iter_mut().enumerate() {
if !keep.contains(&TypePaint::Item(j)) {
*item.paint_mut() = vectorcraft_color::Paint::None;
}
}
for (i, run) in t.runs.iter_mut().enumerate() {
if !keep.contains(&TypePaint::Fill(i)) {
run.style.fill = vectorcraft_color::Paint::None;
}
if !keep.contains(&TypePaint::Stroke(i)) {
run.style.stroke = vectorcraft_color::Paint::None;
}
}
(n, t)
}
/// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of
/// the pixel: its color and opacity scaled by [`edge_coverage`] of `c` divided by `c`. A pixel the
/// type covers wholly or not at all is kept.
+63
View File
@@ -154,6 +154,69 @@ fn the_methods_keep_the_opacity_of_the_paint() {
}
}
/// Crisp and Strong reshape the edges of each of the type's paints on its own: where two
/// half-opaque fills of the object overlap, each edge pixel shows the first fill with the
/// method's share of the pixel under the second fill with that share.
#[test]
fn the_methods_reshape_each_paint_on_its_own() {
let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) };
let opts = RenderOptions::default();
let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &opts);
assert!(partial(&coverage) > 200, "{} partial pixels", partial(&coverage));
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
let mut d = page(m, Some(half.clone()));
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
d.node_mut(id).unwrap().appearance.items.push(AppearanceItem::Fill(half.clone()));
let drawn = alphas(&d, &opts);
// Two half-opaque layers that each show `e` of the pixel, within a step of rounding in the
// coverage and in the result.
let both = |c: u8| {
let e = f64::from(edge_coverage(m, c)) / 255.0;
255.0 * (1.0 - (1.0 - 0.5 * e).powi(2))
};
let wrong: Vec<_> = coverage
.iter()
.zip(&drawn)
.enumerate()
.filter(|(_, (c, v))| {
let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(both);
let (lo, hi) = (near.iter().copied().fold(f64::MAX, f64::min), near.iter().copied().fold(0.0, f64::max));
f64::from(**v) < lo - 1.5 || f64::from(**v) > hi + 1.5
})
.map(|(i, (c, v))| (i, *c, *v))
.take(5)
.collect();
assert!(wrong.is_empty(), "{m:?}: (pixel, coverage, drawn) {wrong:?}");
}
}
/// Crisp and Strong type with several faint fills of the object draws the pixels inside the
/// characters as Sharp draws them: the fills are composited there before their colors are rounded.
#[test]
fn the_methods_keep_faint_fills_inside_the_characters() {
let faint = FillLayer { opacity: 0.01, ..FillLayer::new(Paint::solid(Color::BLACK)) };
let draw = |m: TypeAntiAlias| {
let mut d = page(m, Some(faint.clone()));
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
let n = d.node_mut(id).unwrap();
for _ in 0..7 {
n.appearance.items.push(AppearanceItem::Fill(faint.clone()));
}
alphas(&d, &RenderOptions::default())
};
let sharp = draw(TypeAntiAlias::Sharp);
// The pixels the characters cover wholly.
let coverage = alphas(&page(TypeAntiAlias::Sharp, None), &RenderOptions::default());
let inside: Vec<usize> = coverage.iter().enumerate().filter(|(_, c)| **c == 255).map(|(i, _)| i).collect();
assert!(inside.len() > 200, "{} pixels inside the characters", inside.len());
assert!(inside.iter().all(|i| sharp[*i] > 0), "the faint fills show inside the characters");
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
let drawn = draw(m);
let wrong: Vec<_> = inside.iter().filter(|i| drawn[**i] != sharp[**i]).take(5).map(|i| (*i, sharp[*i], drawn[*i])).collect();
assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}");
}
}
/// In an export that composites with floating point, None, Crisp and Strong type composite its
/// translucent fills as precisely as Sharp type: inside the characters each method draws the
/// pixels Sharp draws, within the one step of rounding that keeping the type's pixels as 8-bit