Fix #787: raster exports keep translucent edges over opaque art opaque

The renderer composited in 8-bit integers, so where a partly transparent
anti-aliased edge crossed opaque art (or a background colour) the result
came out at alpha 254 on some pixels, and PNG, TIFF and WebP files were
written with an alpha channel. Raster exports (and Save for Web) now
composite in floating point (vello_cpu's f32 pipeline, enabled as a
feature), offscreen groups, masks and patterns included; the canvas keeps
the faster 8-bit path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-10 11:11:45 +07:00
parent 1db82cbc02
commit 4099ebf3c9
8 changed files with 61 additions and 9 deletions

View File

@ -54,7 +54,7 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
wat = "1.228"
# rendering / formats
vello_cpu = "0.2"
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
krilla = "0.8"

File diff suppressed because one or more lines are too long

View File

@ -582,7 +582,8 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result<WebRender,
let doc = doc.without_edit_modes();
let (region, scale) = region(&doc, s)?;
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 4]);
let opts = vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, ..Default::default() };
let opts =
vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, precise: true, ..Default::default() };
let img = vectorcraft_render::Renderer::new().render_region_with(&doc, region, scale, &opts);
Ok(WebRender { rgba: img.to_straight(), width: img.width, height: img.height, region, scale })
}

View File

@ -87,7 +87,7 @@ impl RasterExportOptions {
}
/// What the renderer draws for `format`: template layers and guides left out, over the background
/// (white for a JPEG or a flat PSD without one).
/// (white for a JPEG or a flat PSD without one), composited in floating point.
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
let flat = format == RasterFormat::Jpeg || (format == RasterFormat::Psd && !self.psd.layers);
let background = self.background.or(flat.then_some([255; 3]));
@ -95,6 +95,8 @@ impl RasterExportOptions {
background: background.map(|[r, g, b]| [r, g, b, 255]),
skip_templates: true,
anti_alias: self.anti_alias,
// Opaque art stays exactly opaque where translucent edges cross it (#787).
precise: true,
..Default::default()
}
}

View File

@ -110,6 +110,44 @@ fn backgrounds_are_opaque_or_transparent() {
assert_eq!(webp.get_pixel(0, 0).0, [0, 0, 0, 255]);
}
/// #787: translucent art over opaque art (or a background) stays exactly opaque, its anti-aliased
/// edges too, so the file is written as RGB.
#[test]
fn translucent_edges_over_opaque_art_stay_opaque() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let under = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut over =
Node::path(d.alloc_id(), shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
over.opacity = 0.5;
let only_over = {
let mut d = d.clone();
d.insert(Some(l), 0, over.clone()).unwrap();
d
};
d.insert(Some(l), 0, under).unwrap();
d.insert(Some(l), 1, over).unwrap();
// Over a black background at 72 ppi, and over an opaque rectangle at a scale that puts the
// edges on fractions of a pixel.
for (doc, o) in [
(&only_over, RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() }),
(&d, RasterExportOptions { ppi: 72.0 * 0.3515625, ..Default::default() }),
] {
let png = Renderer::new().export_region(doc, doc.artboards[0].rect, RasterFormat::Png, &o).unwrap();
let img = image::load_from_memory(&png).unwrap().to_rgba8();
let below: Vec<_> = img.pixels().filter(|p| p[3] < 255).collect();
assert!(below.is_empty(), "ppi {}: {} pixels below alpha 255, e.g. {:?}", o.ppi, below.len(), below.first());
assert_eq!(png[25], 2, "IHDR colour type: RGB");
}
// The inside is half white over black.
let img = export(&only_over, RasterFormat::Png, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
assert!(img.get_pixel(50, 50).0[..3].iter().all(|c| (127..=128).contains(c)), "{:?}", img.get_pixel(50, 50));
}
#[test]
fn interlaced_export_decodes_to_the_same_pixels() {
let d = circle_doc();

View File

@ -137,7 +137,7 @@ impl Renderer {
fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc<Pixmap> {
ctx.flush();
let mut all = Pixmap::new(ctx.width(), ctx.height());
ctx.render(&mut all, &mut self.resources);
ctx.render_with(&mut all, &mut self.resources, self.raster);
ctx.reset();
let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) {
None
@ -171,7 +171,7 @@ impl Renderer {
(self.nested, self.backdrop, self.clip_paths) = outer;
ctx.flush();
let mut pm = Pixmap::new(r.w, r.h);
ctx.render(&mut pm, &mut self.resources);
ctx.render_with(&mut pm, &mut self.resources, self.raster);
pm
}

View File

@ -110,6 +110,10 @@ pub struct RenderOptions {
/// Screen view in isolation mode: the isolated group or layer. Everything around it draws
/// dimmed ([`ISOLATION_DIM`]).
pub isolated: Option<NodeId>,
/// Composite in floating point (exports): translucent art over opaque art comes out exactly
/// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off,
/// the faster 8-bit pipeline (the canvas).
pub precise: bool,
}
/// The opacity of the art around an isolated group or layer.
@ -181,6 +185,7 @@ impl Default for RenderOptions {
trace_views: false,
smooth_images: true,
isolated: None,
precise: false,
}
}
}
@ -362,6 +367,9 @@ pub struct Renderer {
/// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing
/// it): drawing stops at [`MAX_INLINE_DEPTH`].
inline_depth: u32,
/// How the frame being drawn is rasterized ([`RenderOptions::precise`]): its offscreen groups,
/// masks and patterns too.
raster: vello_cpu::RasterizerSettings,
/// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the
/// frame (`stamp`) and document they were found in.
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
@ -453,6 +461,7 @@ impl Renderer {
adjusted: Default::default(),
dim_images: None,
inline_depth: 0,
raster: vello_cpu::RasterizerSettings::default(),
isolation: None,
isolation_settled: false,
}
@ -532,6 +541,8 @@ impl Renderer {
};
// `reset` keeps the threshold of the previous render: set it every time.
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed };
self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() };
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(f.ink.fixed(bg));
@ -560,7 +571,7 @@ impl Renderer {
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
ctx.render_with(&mut pm, &mut self.resources, self.raster);
if threads == 0 {
self.ctx_st = Some(ctx);
} else {
@ -795,7 +806,7 @@ impl Renderer {
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
mctx.flush();
let mut pm = Pixmap::new(w, h);
mctx.render(&mut pm, &mut self.resources);
mctx.render_with(&mut pm, &mut self.resources, self.raster);
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
}

View File

@ -88,7 +88,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut r.resources);
ctx.render_with(&mut pm, &mut r.resources, r.raster);
pm
}