Skip to content

Commit ae938a3

Browse files
Brooooooklynclaude
andauthored
fix: putImageData bypassed deferred rendering PageRecorder (#1206)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1657cfb commit ae938a3

File tree

7 files changed

+201
-52
lines changed

7 files changed

+201
-52
lines changed

__test__/regression.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,71 @@ import { snapshotImage } from './image-snapshot'
99

1010
const __dirname = dirname(fileURLToPath(import.meta.url))
1111

12+
// https://github.com/Brooooooklyn/canvas/issues/1204
13+
test('putImageData should modify the canvas', (t) => {
14+
const canvas = createCanvas(100, 100)
15+
const ctx = canvas.getContext('2d')
16+
17+
// Fill canvas with red
18+
ctx.fillStyle = 'red'
19+
ctx.fillRect(0, 0, 100, 100)
20+
21+
// Get image data and modify to green
22+
const imageData = ctx.getImageData(0, 0, 10, 10)
23+
for (let i = 0; i < imageData.data.length; i += 4) {
24+
imageData.data[i] = 0 // R
25+
imageData.data[i + 1] = 255 // G
26+
imageData.data[i + 2] = 0 // B
27+
imageData.data[i + 3] = 255 // A
28+
}
29+
30+
// putImageData should replace pixels at (0,0)
31+
ctx.putImageData(imageData, 0, 0)
32+
33+
// Read back and verify the pixels were actually written
34+
const result = ctx.getImageData(0, 0, 1, 1)
35+
t.is(result.data[0], 0, 'R should be 0 (green)')
36+
t.is(result.data[1], 255, 'G should be 255 (green)')
37+
t.is(result.data[2], 0, 'B should be 0 (green)')
38+
t.is(result.data[3], 255, 'A should be 255')
39+
})
40+
41+
// https://github.com/Brooooooklyn/canvas/issues/1204
42+
test('putImageData with dirty rect should modify the canvas', (t) => {
43+
const canvas = createCanvas(100, 100)
44+
const ctx = canvas.getContext('2d')
45+
46+
// Fill canvas with blue
47+
ctx.fillStyle = 'blue'
48+
ctx.fillRect(0, 0, 100, 100)
49+
50+
// Create green image data
51+
const imageData = ctx.getImageData(0, 0, 20, 20)
52+
for (let i = 0; i < imageData.data.length; i += 4) {
53+
imageData.data[i] = 0 // R
54+
imageData.data[i + 1] = 255 // G
55+
imageData.data[i + 2] = 0 // B
56+
imageData.data[i + 3] = 255 // A
57+
}
58+
59+
// putImageData with dirty rect
60+
ctx.putImageData(imageData, 10, 10, 0, 0, 10, 10)
61+
62+
// Pixel at (15, 15) should be green (inside dirty rect)
63+
const inside = ctx.getImageData(15, 15, 1, 1)
64+
t.is(inside.data[0], 0, 'R should be 0 (green) inside dirty rect')
65+
t.is(inside.data[1], 255, 'G should be 255 (green) inside dirty rect')
66+
t.is(inside.data[2], 0, 'B should be 0 (green) inside dirty rect')
67+
t.is(inside.data[3], 255, 'A should be 255 inside dirty rect')
68+
69+
// Pixel at (25, 25) should still be blue (outside dirty rect)
70+
const outside = ctx.getImageData(25, 25, 1, 1)
71+
t.is(outside.data[0], 0, 'R should be 0 (blue) outside dirty rect')
72+
t.is(outside.data[1], 0, 'G should be 0 (blue) outside dirty rect')
73+
t.is(outside.data[2], 255, 'B should be 255 (blue) outside dirty rect')
74+
t.is(outside.data[3], 255, 'A should be 255 outside dirty rect')
75+
})
76+
1277
test('transform-with-state', async (t) => {
1378
const canvas = createCanvas(256, 256)
1479
const ctx = canvas.getContext('2d')
43 Bytes
Loading

skia-c/skia_c.cpp

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -815,19 +815,23 @@ void skiac_canvas_write_pixels(skiac_canvas* c_canvas,
815815
CANVAS_CAST->writePixels(info, pixels, row_bytes, x, y);
816816
}
817817

818-
void skiac_canvas_write_pixels_dirty(skiac_canvas* c_canvas,
819-
int width,
820-
int height,
821-
uint8_t* pixels,
822-
size_t row_bytes,
823-
size_t length,
824-
float x,
825-
float y,
826-
float dirty_x,
827-
float dirty_y,
828-
float dirty_width,
829-
float dirty_height,
830-
uint8_t cs) {
818+
// putImageData: uses drawImageRect with kSrc blend mode for direct pixel
819+
// replacement. Per HTML spec, putImageData must replace pixels, not composite.
820+
// Uses drawImageRect (recordable by PictureRecorder) instead of
821+
// SkCanvas::writePixels (which only works on raster surfaces).
822+
void skiac_canvas_put_image_data(skiac_canvas* c_canvas,
823+
int width,
824+
int height,
825+
uint8_t* pixels,
826+
size_t row_bytes,
827+
size_t length,
828+
float x,
829+
float y,
830+
float dirty_x,
831+
float dirty_y,
832+
float dirty_width,
833+
float dirty_height,
834+
uint8_t cs) {
831835
auto color_space = COLOR_SPACE_CAST;
832836
auto info =
833837
SkImageInfo::Make(width, height, SkColorType::kRGBA_8888_SkColorType,
@@ -838,7 +842,9 @@ void skiac_canvas_write_pixels_dirty(skiac_canvas* c_canvas,
838842
auto dst_rect =
839843
SkRect::MakeXYWH(x + dirty_x, y + dirty_y, dirty_width, dirty_height);
840844
const auto sampling = SkSamplingOptions(SkCubicResampler::Mitchell());
841-
CANVAS_CAST->drawImageRect(image, src_rect, dst_rect, sampling, nullptr,
845+
SkPaint paint;
846+
paint.setBlendMode(SkBlendMode::kSrc);
847+
CANVAS_CAST->drawImageRect(image, src_rect, dst_rect, sampling, &paint,
842848
SkCanvas::kFast_SrcRectConstraint);
843849
}
844850

skia-c/skia_c.hpp

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -897,19 +897,19 @@ void skiac_canvas_write_pixels(skiac_canvas* c_canvas,
897897
size_t row_bytes,
898898
int x,
899899
int y);
900-
void skiac_canvas_write_pixels_dirty(skiac_canvas* c_canvas,
901-
int width,
902-
int height,
903-
uint8_t* pixels,
904-
size_t row_bytes,
905-
size_t length,
906-
float x,
907-
float y,
908-
float dirty_x,
909-
float dirty_y,
910-
float dirty_width,
911-
float dirty_height,
912-
uint8_t cs);
900+
void skiac_canvas_put_image_data(skiac_canvas* c_canvas,
901+
int width,
902+
int height,
903+
uint8_t* pixels,
904+
size_t row_bytes,
905+
size_t length,
906+
float x,
907+
float y,
908+
float dirty_x,
909+
float dirty_y,
910+
float dirty_width,
911+
float dirty_height,
912+
uint8_t cs);
913913
void skiac_canvas_draw_picture(skiac_canvas* c_canvas,
914914
skiac_picture* c_picture,
915915
skiac_matrix* c_matrix,

src/ctx.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2788,12 +2788,33 @@ impl CanvasRenderingContext2D {
27882788
if dirty_width <= 0f32 || dirty_height <= 0f32 {
27892789
return;
27902790
}
2791+
// Deferred mode: record via PageRecorder on a fresh layer (no clip/transform)
2792+
// put_image_data uses drawImageRect with kSrc blend (pixel replacement),
2793+
// which IS recordable by PictureRecorder unlike SkCanvas::writePixels.
2794+
if let Some(ref recorder) = self.context.page_recorder {
2795+
let dx_f = dx as f32;
2796+
let color_space = image_data.color_space;
2797+
recorder.borrow_mut().put_pixels(|canvas| {
2798+
canvas.put_image_data(
2799+
image_data,
2800+
dx_f,
2801+
dy as f32,
2802+
dirty_x,
2803+
dirty_y,
2804+
dirty_width,
2805+
dirty_height,
2806+
color_space,
2807+
);
2808+
});
2809+
return;
2810+
}
2811+
// Direct mode (SVG/PDF): write to surface canvas with inverted transform
27912812
let inverted = self.context.surface.canvas.get_transform_matrix().invert();
27922813
self.context.surface.canvas.save();
27932814
if let Some(inverted) = inverted {
27942815
self.context.surface.canvas.concat(&inverted);
27952816
};
2796-
self.context.surface.canvas.write_pixels_dirty(
2817+
self.context.surface.canvas.put_image_data(
27972818
image_data,
27982819
dx as f32,
27992820
dy as f32,
@@ -2805,6 +2826,20 @@ impl CanvasRenderingContext2D {
28052826
);
28062827
self.context.surface.canvas.restore();
28072828
} else {
2829+
// Deferred mode: use put_image_data with full image dimensions
2830+
// because write_pixels (SkCanvas::writePixels) is NOT recordable by PictureRecorder
2831+
if let Some(ref recorder) = self.context.page_recorder {
2832+
let dx_f = dx as f32;
2833+
let dy_f = dy as f32;
2834+
let w = image_data.width as f32;
2835+
let h = image_data.height as f32;
2836+
let color_space = image_data.color_space;
2837+
recorder.borrow_mut().put_pixels(|canvas| {
2838+
canvas.put_image_data(image_data, dx_f, dy_f, 0.0, 0.0, w, h, color_space);
2839+
});
2840+
return;
2841+
}
2842+
// Direct mode (SVG/PDF): write pixels directly
28082843
self.context.surface.canvas.write_pixels(image_data, dx, dy);
28092844
}
28102845
}

src/page_recorder.rs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,26 @@ impl PageRecorder {
118118
}
119119
}
120120

121+
/// Begin a new recording and restore the current canvas state (save stack, clip, transform).
122+
fn resume_recording(&mut self) {
123+
self
124+
.current
125+
.begin_recording(0.0, 0.0, self.width, self.height);
126+
if let Some(canvas) = self.current.get_recording_canvas() {
127+
for _ in 0..self.save_count {
128+
canvas.save();
129+
}
130+
if let Some(ref clip_path) = self.current_clip {
131+
canvas.reset_transform();
132+
canvas.set_clip_path(clip_path);
133+
}
134+
if let Some(ref transform) = self.current_transform {
135+
canvas.set_transform(transform);
136+
}
137+
}
138+
self.changed = false;
139+
}
140+
121141
/// Promote current recording to a layer if changed (lazy finalization)
122142
fn promote_layer(&mut self) {
123143
if self.changed {
@@ -136,27 +156,7 @@ impl PageRecorder {
136156
);
137157
}
138158
}
139-
// Resume recording
140-
self
141-
.current
142-
.begin_recording(0.0, 0.0, self.width, self.height);
143-
// Restore canvas state on the new recording canvas
144-
if let Some(canvas) = self.current.get_recording_canvas() {
145-
// First restore save stack depth so transform/clip are applied at correct level
146-
for _ in 0..self.save_count {
147-
canvas.save();
148-
}
149-
// Restore clip state first (clip is stored in device space, apply at identity)
150-
if let Some(ref clip_path) = self.current_clip {
151-
canvas.reset_transform();
152-
canvas.set_clip_path(clip_path);
153-
}
154-
// Then restore transform state
155-
if let Some(ref transform) = self.current_transform {
156-
canvas.set_transform(transform);
157-
}
158-
}
159-
self.changed = false;
159+
self.resume_recording();
160160
}
161161
}
162162

@@ -276,6 +276,46 @@ impl PageRecorder {
276276
surface.read_pixels(x, y, width, height, color_space)
277277
}
278278

279+
/// Write pixel data as a separate layer, bypassing clip and transform.
280+
/// This is used for putImageData which per HTML spec must ignore
281+
/// the current transform, clip, globalAlpha, and compositing state.
282+
///
283+
/// The approach:
284+
/// 1. Promote current recording to a layer (preserving pending draw operations)
285+
/// 2. Create a fresh recording (no clip, identity transform) and execute the draw
286+
/// 3. Promote that recording as another layer
287+
/// 4. Start a new recording with the original state restored
288+
pub fn put_pixels<F>(&mut self, f: F)
289+
where
290+
F: FnOnce(&mut Canvas),
291+
{
292+
// Step 1: Promote current recording if it has changes
293+
if self.changed {
294+
if let Some(picture) = self.current.finish_recording_as_picture() {
295+
self.layers.push(picture);
296+
self.cached_picture = None;
297+
}
298+
self.changed = false;
299+
}
300+
301+
// Step 2: Fresh recording for pixel data (clean canvas: no clip, identity transform)
302+
self
303+
.current
304+
.begin_recording(0.0, 0.0, self.width, self.height);
305+
if let Some(canvas) = self.current.get_recording_canvas() {
306+
f(canvas);
307+
}
308+
309+
// Step 3: Promote the pixel data recording as a layer
310+
if let Some(picture) = self.current.finish_recording_as_picture() {
311+
self.layers.push(picture);
312+
self.cached_picture = None;
313+
}
314+
315+
// Step 4: Start new recording and restore state
316+
self.resume_recording();
317+
}
318+
279319
/// Get recording canvas for direct access (needed for SVG/PDF direct mode)
280320
pub fn get_recording_canvas(&mut self) -> Option<&mut Canvas> {
281321
self.changed = true;

src/sk.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ pub mod ffi {
606606
y: i32,
607607
);
608608

609-
pub fn skiac_canvas_write_pixels_dirty(
609+
pub fn skiac_canvas_put_image_data(
610610
canvas: *mut skiac_canvas,
611611
width: i32,
612612
height: i32,
@@ -2815,7 +2815,10 @@ impl Canvas {
28152815
}
28162816
}
28172817

2818-
pub fn write_pixels_dirty(
2818+
/// drawImageRect with kSrc blend mode for putImageData.
2819+
/// Replaces destination pixels per HTML spec.
2820+
/// Works on recording canvases (PictureRecorder).
2821+
pub fn put_image_data(
28192822
&mut self,
28202823
image: &ImageData,
28212824
x: f32,
@@ -2827,7 +2830,7 @@ impl Canvas {
28272830
color_space: ColorSpace,
28282831
) {
28292832
unsafe {
2830-
ffi::skiac_canvas_write_pixels_dirty(
2833+
ffi::skiac_canvas_put_image_data(
28312834
self.0,
28322835
image.width as i32,
28332836
image.height as i32,

0 commit comments

Comments
 (0)