Merge pull request #892 from storytold/fix/787-precise-export
Fix #787: raster exports keep translucent edges over opaque art opaque
This commit is contained in:
8 files changed
+61
-9
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user