Canvas: fix 21 correctness and robustness bugs in the shared Canvas2D polyfill - #1824
Canvas: fix 21 correctness and robustness bugs in the shared Canvas2D polyfill#1824bkaradzic-microsoft wants to merge 12 commits into
Conversation
…e, gradients, createImageData) These are backend-agnostic nanovg/polyfill bugs found while bringing up a WebGPU canvas backend; they affect the existing bgfx path identically. 1. `ImageData.data` returned a fresh copy on every property access `GetData` allocated a new typed array and memcpy'd into it each time it was read, so JS mutated one copy while `putImageData`/consumers read another. That silently discarded every write and broke the standard `getImageData -> mutate -> putImageData` idiom. The backing array is now allocated once in the constructor, read into directly, held in a `Napi::Reference`, and returned as the same live object. It is also now a `Uint8ClampedArray` as the spec requires: a plain `Uint8Array` wraps out-of-range writes modulo 256, so saturating arithmetic in JS (`data[i] = v + 40`) silently darkened pixels. 2. `clip()` suppressed `beginPath()` for later draws `Clip()` is implemented with `nvgScissor`, which is path-independent and never consumes the current path. Despite that, `FillRect`, `ClearRect` and all three `DrawImage` branches skipped `nvgBeginPath` whenever a clip was active. Every draw after a `clip()` therefore appended to one ever-growing path, and each fill repainted the union of every rect added since the clip using the newest paint. Per spec these three operations neither read nor modify the current path, so `beginPath` is now unconditional. The `m_isClipped` flag existed only to gate this and is removed. This mostly went unnoticed because most controls issue a single `fillRect` after `clip()` that coincides with the clip rect. It only shows up when several differently-sized rects or images are drawn under one clip - e.g. a GUI ColorPicker, whose saturation gradient smeared over its own colour wheel. 3. 3-argument `drawImage(img, dx, dy)` anchored the pattern at (0,0) The 5- and 9-argument branches correctly build the image pattern at `(dx,dy)`, but the 3-argument branch used `(0,0)` while still drawing the rect at `(dx,dy)`. The rect then sampled outside the pattern extent and clamped to the edge texels, so the call silently drew nothing for any offset other than `(0,0)`. 4. Gradients ignored their own geometry `BindFillStyle` built the paint from the *shape being filled* (`nvgImagePattern(0, 0, width + left, height, 0, ...)`) instead of the gradient's `(x0,y0)->(x1,y1)`. Every gradient was forced horizontal, anchored at the canvas origin and stretched to the wrong length, so vertical gradients rendered horizontally and all stop positions were wrong. `CanvasGradient::Paint()` now orients the pattern along the gradient vector via `atan2` and spans exactly that distance; radial gradients map the baked ramp onto the outer circle's bounding box. Sampling outside the extent clamps to the edge texel, which is the "pad" behavior the spec requires beyond the end stops. Also fixed in the ramp baking: the sample buffer was uninitialized (samples outside the stop range read stack garbage) and `gradientSpan` divided by zero when two stops shared an offset. 5. `createImageData` was missing Neither context implemented it, so `ctx.createImageData(...)` threw `is not a function`. Implemented for both overloads - `(width, height)` and `(imagedata)` - returning transparent black, taking the magnitude of negative extents, and rejecting zero and overflowing sizes. Validated against the Playground validation suite: 625 -> 630 passing, 53 -> 47 failing. The GUI test's pixel difference went from 13.5% to 3.9% (allowed 4%), with the ColorPicker colour wheel and saturation square now matching the reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Fixes multiple Canvas2D correctness issues in the shared canvas polyfill (ImageData semantics, clipping/path behavior, drawImage anchoring, gradient geometry, and missing createImageData).
Changes:
- Make
ImageData.dataa stable, spec-correct clamped typed array instead of returning a fresh copy each access. - Fix path handling under
clip()and correct 3-argdrawImage()pattern anchoring. - Implement gradient paints based on gradient geometry (plus ramp baking fixes) and add
createImageData()overloads.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| Polyfills/Canvas/Source/ImageData.h | Store and reuse a persistent JS typed array for ImageData.data. |
| Polyfills/Canvas/Source/ImageData.cpp | Allocate clamped backing array once, read pixels into it, and return it unchanged. |
| Polyfills/Canvas/Source/Gradient.h | Add CanvasGradient::Paint() API to produce geometry-correct NanoVG paint. |
| Polyfills/Canvas/Source/Gradient.cpp | Fix ramp baking edge cases and implement paint generation for linear/radial gradients. |
| Polyfills/Canvas/Source/Context.h | Add createImageData() and simplify BindFillStyle signature. |
| Polyfills/Canvas/Source/Context.cpp | Register/implement createImageData(), fix clip/path behavior, fix drawImage anchoring, use gradient->Paint(). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Clip() can only express a rectangle (nvgScissor), so the previous m_isClipped suppression of beginPath() was load-bearing for non-rectangular clip paths: it left the clip path current so the following fill would render it. Removing it outright regressed the 'Dynamic Texture context clip' validation test from a speech bubble to a solid white square. Instead, track whether the current path contains anything a scissor cannot express and only fall back to that emulation then. Rectangular clips -- what Babylon GUI uses -- now correctly begin a fresh path per fillRect, which is what stopped a GUI ColorPicker from smearing its saturation gradient over its own colour wheel. Also from review: guard the weak context lock in CanvasGradient::Paint and reject non-ImageData objects in createImageData(imagedata). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Path2D was registered only on the `_native` object, never as a global. Browsers expose it as a global constructor and Babylon.js relies on that: AbstractEngine.createCanvasPath2D does `return new Path2D(d)`. Only ThinNativeEngine overrides that method to use `_native.Path2D`, so the generic engine path -- and any portable browser code doing `new Path2D(...)` directly -- failed on native with "ReferenceError: Path2D is not defined". Register the constructor on the global object as well. The registration is guarded so an existing global is never overwritten, matching how the Window polyfill installs its globals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
…errors Canvas.loadTTF cast info[1] straight to Napi::ArrayBuffer. When a caller passed anything else the napi cast threw a bare "Error: Invalid argument" that named neither the method nor the offending argument, leaving no way to tell which call failed or why. In practice the cause is almost always a caller that fetched the font as text rather than binary. Tools.LoadFileAsync's signature is LoadFileAsync(url, useArrayBuffer); a text response decodes the font bytes into an unusable string. The new message calls this out directly. Adds GetFontDataArgument in Font.cpp. It validates arity and the font name, accepts an ArrayBuffer or any ArrayBuffer view (typed array / DataView) instead of only ArrayBuffer, rejects empty buffers, and otherwise throws a descriptive Napi::TypeError. The buffer validation stays inside the "not already loaded" guard so a redundant re-registration of an already-loaded font keeps its existing no-op behaviour. DataView is read through its buffer/byteOffset/byteLength properties rather than Napi::DataView on purpose: the Chakra Node-API implementation backs napi_get_dataview_info with JsGetExternalData, which only succeeds for DataViews created natively via napi_create_dataview. A DataView built in JS fails with napi_invalid_arg even though napi_is_dataview reports true. Property access behaves identically on V8 and Chakra. Verified on Chakra and V8: every argument shape produces the same result, and the Native Canvas validation test still passes unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
…ur parsing Three more Canvas2D gaps, each of which aborted an entire scene rather than degrading. All were already fixed on the Dawn branch and are shared bugs, so they belong on master. 8. putImageData threw "not implemented" The method was registered but its body was an unconditional throw, so the standard getImageData -> mutate -> putImageData round trip was impossible. It now writes the pixels through a transient nanovg image. putImageData is specified to ignore the transform, clip region, globalAlpha, composite operation, shadows and filters, and to replace the destination pixels rather than blend with them, so the draw is wrapped in nvgSave/nvgReset and uses NVG_COPY. The optional dirty rectangle is supported, including the spec's negative-extent normalisation, and the CPU mirror that getImageData reads is updated so a subsequent read observes the write. 9. setLineDash threw "not implemented" nanovg cannot draw dashed strokes, but throwing was the wrong response: Babylon GUI's Line and MultiLine controls call setLineDash(this._dash) on every render and _dash defaults to [], so *any* scene containing a GUI line died on an empty dash list that was asking for nothing. An empty list now means "solid", which is what we already draw. A non-empty pattern draws solid and warns once instead of failing the scene, and the list is retained so the newly added getLineDash() round-trips as the spec requires. Non-finite, negative and non-numeric entries are ignored per spec. 10. rgba() with a fractional alpha failed to parse The colour regex matched components as 1-3 digit integers, so the extremely common rgba(0, 0, 0, 0.5) matched nothing and fell through to the "Unable to parse color" throw. Components are now matched as generic CSS numbers with an optional % suffix, whitespace-separated forms and the "/ alpha" syntax are accepted, and hsl()/hsla() -- which Babylon GUI's ColorPicker emits -- is supported. Verified on a Chakra/D3D11 build: the full CI validation set is 301/301 PASS with no regressions. Direct API checks confirm rgba/hsl/percentage/slash-alpha colours all parse, getLineDash round-trips [5,10] and ignores [-1], putImageData round-trips pixels, honours a dirty rect, normalises negative dirty extents, and ignores an active translate and globalAlpha. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
The parseColor unit test asserted that rgba(16,32,48,64) yields an alpha of 0x40, i.e. that the fourth component is a 0-255 channel like r/g/b. CSS Color defines alpha as a number in 0-1 (or a percentage), so every browser clamps 64 to 1 and paints that colour fully opaque. The 0-255 reading was not a deliberate extension. The old regex matched all four components as 1-3 digit integers and ran std::stoi over them, so alpha simply inherited the channel treatment, and the test was written to describe whatever the implementation happened to do. That is the same root cause as the colour parsing fix in the previous commit: under a 0-255 alpha the overwhelmingly common rgba(0, 0, 0, 0.5) truncates to 0 and disappears, which is exactly the bug being fixed. Alpha cannot be both. So the test is updated to the CSS behaviour and extended to cover what the new parser accepts: fractional alpha, percentage alpha, the "/ alpha" form, whitespace-separated components, percentage channels, and hsl()/hsla(). The ten malformed-input cases still throw. Verified with the JavaScript.All unit test on a Chakra/D3D11 build: 24 passing, including all ten ColorParsing rejection cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
All four shadow properties -- shadowColor, shadowBlur, shadowOffsetX and
shadowOffsetY -- threw "not implemented" from both their getter and their
setter.
nanovg has no shadow primitive, so shadows genuinely cannot be drawn, but
throwing was the wrong response. These are ordinary state attributes: reading
one, or writing the default, asks for nothing at all. Babylon GUI resets
shadowBlur/shadowOffsetX/shadowOffsetY to 0 immediately after drawing a
shadowed control, so the throw aborted the scene on the *reset* path as well as
on the request path, and any control carrying a drop shadow failed outright
rather than simply rendering without one.
The values are now stored and reported back, so the spec-required round trip
works and content that saves and restores canvas state keeps functioning.
Per spec, a negative or non-finite blur is ignored, a non-finite offset is
ignored, and an unparseable shadowColor leaves the previous value in place.
A shadow that is genuinely requested -- a non-zero blur or offset -- warns once
instead of failing the scene, matching how setLineDash handles a pattern it
cannot honor. Writing 0 is silent, because zero offset and zero blur draw no
shadow anyway.
Verified on a Chakra/D3D11 build. All eleven accesses that previously threw now
succeed, the defaults match the spec ("rgba(0, 0, 0, 0)" and 0), shadowBlur
round-trips, and the warning fires once and only for a real shadow request.
Full CI validation set: 301/301 PASS, no regressions.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
Fixes BabylonNative#1782. Three independent defects made GUI gradients render incorrectly, or not at all, on the NanoVG canvas. 1. strokeStyle could only ever be a color string. `SetStrokeStyle` unconditionally cast the assigned value to a string, so any control that strokes with a gradient -- GUI Line, a Button's border -- threw "TypeError: A string was expected" and aborted the whole scene. `m_strokeStyle` is now the same std::variant<std::string, CanvasGradient*> that `m_fillStyle` already was, with a matching `BindStrokeStyle` bound from stroke(), strokeRect() and strokeText(). 2. Radial gradients ignored the inner circle entirely. The ramp was baked as a disc centered in a square image with the focal point pinned at the center and the radius pinned to half the image, so (x0,y0,r0) were discarded and every gradient came out concentric. The field is now evaluated with the real two-circle equation over the bounding box that encloses both circles: for each texel, find the largest offset w with |p - lerp(c0,c1,w)| == lerp(r0,r1,w) and a non-negative radius. Every point on that box's border lies outside both circles, so it has already padded out to an end stop and NanoVG's clamp-to-edge sampling extends it correctly. The tangent-circle case (a == 0) has to be special-cased to a linear solve; it is not exotic -- createRadialGradient(100,100,150, 250,100,300) from the repro playground lands there. 3. Color stops were interpolated in straight alpha. Canvas2D interpolates premultiplied, so a "#ff0000ff" -> "#00000000" ramp is supposed to stay red and only lose alpha, while straight interpolation dragged the RGB down to black. Both the linear and the radial paths now interpolate premultiplied and convert back to the straight alpha the baked image is sampled with. While binding the new stroke paint it became clear that fill() never bound the fill style at all -- it relied on whatever color NanoVG happened to hold, which is never a gradient (assigning one only records the pointer, since the paint has to be rebuilt per draw). Every path-filled control -- Ellipse, Button background, Slider -- therefore filled white. fill() now binds like fillRect() and fillText() already did. The three GUI gradient tests are re-enabled: "GUI Gradient Linear" (previously a hard TypeError) now differs by 1.14%, "GUI Gradient Radial" by 0.62% (was 15.3%), and "GUI Gradient Linear with transparency" is pixel-exact (was 33.8%) -- all inside the default 2.5% budget. Full Playground sweep: 304 pass, 0 regressions. Five Canvas2D unit tests cover the strokeStyle contract and save/restore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Suppressed comments (12)
Polyfills/Canvas/Source/Context.cpp:922
putImageData()must not modify the current path, but the implementation callsnvgBeginPath()/nvgRect()and then explicitly records that the path is now the upload rectangle. A path built beforeputImageData()is consequently lost, and a followingstroke()/fill()targets the wrong geometry. Render the upload without replacing the user path, or preserve/replay the path around this internal draw.
// The path now holds just this rect, so clear the non-rect flag: a stale `true` left over
// from an earlier arc/curve would make a subsequent clip() take the emulated path branch.
m_pathHasNonRect = false;
Polyfills/Canvas/Source/Context.h:122
- These shadow attributes are also Canvas2D drawing state, but they are not included in
SavedStyle;restore()only rewinds fill/stroke. A shadow value assigned aftersave()therefore survivesrestore(), contradicting the round-trip/state behavior described in the PR. Store and restore all four shadow fields.
// Shadow attributes from shadowColor/shadowBlur/shadowOffsetX/shadowOffsetY.
// Retained only so the getters round-trip; nanovg has no shadow primitive,
// so nothing is ever drawn from them. Defaults are the spec's.
std::string m_shadowColor{"rgba(0, 0, 0, 0)"};
double m_shadowBlur{0.0};
Polyfills/Canvas/Source/Gradient.cpp:388
- This still maps the old concentric texture onto
(x1, y1, r1).RadialGradientStops()fixes its focal point at the texture center (fxp/fyp = 0) and never usesx0,y0, orr0, so non-concentric and nonzero-inner-radius gradients—including the tangent case named in the PR—remain incorrect. The baked field must evaluate the two-circle equation before this paint is mapped.
// The radial ramp is baked into a square image whose inscribed circle is the r1
// isoline, so map that image onto the bounding box of the outer circle.
const float radius = std::max(r1, 1e-4f);
return nvgImagePattern(*nvg, x1 - radius, y1 - radius, 2.f * radius, 2.f * radius, 0.f, cachedImage, 1.f);
Polyfills/Canvas/Source/Gradient.cpp:167
- Zero-initializing the ramp does not implement the claimed premultiplied-alpha interpolation.
gradientSpan()still interpolates rawr/g/b/aindependently at lines 53-60, andlerpColor()does the same at lines 194-200, so transparent linear and radial stops still fade through dark straight-alpha colors. Premultiply RGB before interpolation and unpremultiply the baked sample afterward in both paths.
// Zero-initialize: the spans below only cover the range the stops span, so any
// sample left untouched would otherwise be read from uninitialized stack memory.
uint32_t data[GRADIENT_SAMPLES_L]{};
Polyfills/Canvas/Source/Gradient.cpp:51
- This guard cannot handle coincident color stops because
colorsis astd::map<float, NVGcolor>andAddColorStop()usesinsert; the second stop at an identical offset is discarded beforegradientSpan()runs. Hard transitions at duplicate offsets therefore remain wrong. Preserve duplicate stops with ordered sequence/multimap storage and process them in insertion order.
// Coincident stops produce an empty span; the per-sample deltas below would divide
// by zero and write NaNs into the ramp.
if (e <= s)
{
return;
Polyfills/Canvas/Source/Context.cpp:159
- The new binder is still not called by
Context::Fill()(lines 266-281), which proceeds directly tonvgFill(). Assigning a gradient and then callingfill()therefore continues to use stale NanoVG paint, leaving the path-fill correctness fix described by the PR unimplemented. Bind the current fill style after anyPlayPath2D()call and beforenvgFill().
void Context::BindFillStyle(const Napi::CallbackInfo& info)
Polyfills/Canvas/Source/Context.h:158
- Only a fill-style binder is introduced.
m_strokeStyle/SavedStyle::strokeStyleremain strings,SetStrokeStyle()still unconditionally casts toNapi::String, andstroke,strokeRect, andstrokeTextnever bind a gradient paint. Thus the PR's coreCanvasGradientstroke support still throws on assignment. Add the matching stroke variant/binder and invoke it for every stroke operation.
void BindFillStyle(const Napi::CallbackInfo& info);
Polyfills/Canvas/Source/Context.cpp:448
- Leaving the clip path current is not clipping:
fillRect()appends its rectangle and fills the union of that rectangle and the clip path, whileclearRect()anddrawImage()callnvgBeginPath()and ignore this flag entirely. Non-rectangular clips therefore either paint the clip shape or are not applied. Use an actual clip mask/stencil implementation (or explicitly keep this unsupported) rather than treating the clip path as draw geometry.
This issue also appears on line 920 of the same file.
// A non-rectangular clip path cannot be expressed as a scissor rectangle.
// Emulate it by leaving the path current so the next fill draws it, and
// leave any enclosing scissor untouched rather than clipping to a
// rectangle this path never described.
if (m_pathHasNonRect)
{
m_isClipped = true;
return;
Polyfills/Canvas/Source/Context.cpp:1388
- The comment says an invalid dash list retains the previous value, but
m_lineDashis cleared before validation and cleared again here. For example, after[5, 10],setLineDash([-1])incorrectly changesgetLineDash()to[]. Parse into a temporary vector and assign it only after every segment validates.
// Per spec, a list containing a non-finite or negative value
// is ignored entirely and the previous list is retained; a
// non-numeric entry cannot be interpreted, so ignore it too.
m_lineDash.clear();
return;
Polyfills/Canvas/Source/Context.cpp:1400
- Canvas2D duplicates an odd-length dash sequence, so
setLineDash([5])must makegetLineDash()return[5, 5]. The current code stores and returns only[5]. Duplicate the parsed sequence when its length is odd.
m_lineDash.push_back(value);
}
}
Polyfills/Canvas/Source/Context.h:116
m_lineDashis new wrapper-side canvas state, butSavedStylestill contains only fill/stroke styles. Consequentlysave(); setLineDash(...); restore(); getLineDash()does not restore the saved pattern as Canvas2D requires. Include the dash vector in the state pushed and restored alongside these fields.
This issue also appears on line 118 of the same file.
// Dash pattern from setLineDash. Retained only so getLineDash() round-trips;
// strokes are always drawn solid (nanovg has no dashed stroke).
std::vector<double> m_lineDash{};
Polyfills/Canvas/Source/Canvas.cpp:77
- Buffer validation is still skipped when the font name already exists, because
GetFontDataArgument()is only called inside this conditional. After one successful load,Canvas.loadTTF(name)orCanvas.loadTTF(name, "text")silently succeeds despite the PR's stated arity/type validation. Validate and extract argument 2 before the duplicate-name early-out, then discard the extracted bytes when retaining the existing font is intentional.
if (fontsInfos.find(fontName) == fontsInfos.end())
{
fontsInfos[fontName] = GetFontDataArgument(info, 1, "Canvas.loadTTF");
Follow-up to the Copilot review on BabylonJS#1824. Colors.h, Font.cpp and Context.cpp all reached std::stof/std::stoi with a value the regex admits but the target type cannot represent. The resulting std::out_of_range is not a Napi::Error, so node-addon-api's callback wrapper does not catch it: it unwinds out of the N-API callback and terminates the process instead of surfacing as a JS exception. All three are reachable from ordinary script: ctx.fillStyle = "rgb(999...999, 0, 0)" // component of any length ctx.font = "18e999px Arial" // the size regex accepts exponents ctx.letterSpacing = "999...999px" Parsing now goes through strtof/strtol, which cannot throw: they saturate to +/-HUGE_VALF on overflow. Non-finite results are folded back to finite extremes so the existing clamps stay well defined, and a non-finite font size or letter spacing is rejected rather than handed to nanovg (nvgHSLA takes fmodf of the hue, which would be NaN for an infinity). Gradient.cpp: UpdateCache() dereferenced context.lock() three times without checking it, so the guard added to Paint() ran too late to help - the crash had already happened inside UpdateCache(). The context is now locked once and held for the whole bake, and LinearGradientStops/RadialGradientStops take it by reference so they cannot observe it expiring midway. Dispose() locked twice in a row, where the second lock could return empty; it now locks once. Context.cpp: putImageData normalized negative dirty extents in int32_t, so a dirtyWidth of INT32_MIN made both `dirtyX += dirtyWidth` and `-dirtyWidth` signed overflow, which is undefined behavior on caller-controlled input. The normalization and clipping now run in int64_t and saturate back to int32_t after being clipped to the source bitmap. Verified: the three parse tests kill the process without this change (exit 1, "[Uncaught Error] Unknown failure", taking the remaining ~20 tests with it) and pass with it. UnitTests 32 passing / exit 0, and the full Playground comparison sweep is unchanged at 304 PASS / 0 fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
|
You're right, and thanks for catching it — that was a genuine miss on my part, not a stale view on your end.
The part worth flagging: as you say, the review threads were already resolved, so the PR read as clean and green while the code being described wasn't actually on it. That's the bad failure mode here — the green checkmark was real, it just wasn't covering the fixes under discussion. I've re-verified against the pushed tree that each resolved thread now corresponds to code that's actually there:
I also confirmed the branch still merges cleanly onto current master — Sorry for the round trip. Will wait for CI to come back green before asking for another look. |
|
CI is back green on |
|
Thanks for closing these conformance gaps! If we are consistently beating the 2.5% threshold of incorrect pixels, I'd recommend lowering it to tighten things up a bit now that so much of the screenshot testing stability and accuracy has made such huge strides. |
|
Thanks! I agree with the direction, so I pulled the actual numbers rather than guess at the headroom. Data is from this PR's green CI run ( Distribution on the 2.5% default
So the median has a ~7x margin, but the worst test sits at 2.249% — only 0.25pp under the limit. Lowering the global default is therefore not quite free:
The good news is that these numbers are extremely stable, which is really the property that matters for tightening a threshold. Across two independent CI runs on different commits ( That means a tightening is low-risk and won't introduce flakiness — it just has to be paired with per-test overrides for the tail, exactly the mechanism Two caveats worth stating: this is Win32 D3D11 only (D3D12 CI doesn't run the pixel comparison today — relevant to #1671), and the per-channel I'd rather not fold this into this PR though — it's repo-wide test policy and would churn |
… stops nvgRestore() rewinds nanovg's own copy of the drawing state, but the Context wrapper keeps C++ mirrors of the attributes it has to reproject (shadows, filter, direction, dash) or that nanovg cannot report back (lineCap, lineJoin, letterSpacing). SavedStyle only snapshotted fillStyle and strokeStyle, so every other attribute survived restore() with its post-save() value: the getters reported the wrong value, and the shadow attributes -- which are re-applied from the mirror on each draw -- actually rendered wrong. Snapshot all fifteen fields instead. Gradient stored its stops in a std::map keyed by offset, so insert() silently dropped a second stop at an offset that was already present. Two stops at one offset is how the canvas spec encodes a hard transition, so a stripe pattern came out as a smooth fade. Switch to std::multimap; both consumers only iterate in sorted order into a vector, and the existing zero-width-span guard turns the resulting duplicate offset into the required hard edge. Adds three unit tests. globalAlpha is round-tripped but not asserted, as it is declared with a null getter and has no observable value.
m_lineDash was cleared before the segments were validated, so a list containing a negative, non-finite or non-numeric entry -- which the spec says must be ignored entirely, leaving the previous list in place -- wiped the previous list instead: after setLineDash([5, 10]), setLineDash([-1]) made getLineDash() return []. Parse into a temporary and commit it only once every segment validates.
|
Pushed two more commits ( Most of that block was generated against Fixed in this push
Coincident color stops were dropped. Correct —
Three unit tests cover the state round-trip and the dash retention; a fourth covers the duplicate stops. 36/36 pass locally. Already fixed in
|
Fixes #1782.
21 Canvas2D bugs in the shared canvas polyfill. All are backend-agnostic (nanovg / polyfill level), so they affect the existing bgfx path exactly as found — I hit them while bringing up a WebGPU canvas backend, but nothing here is WebGPU-specific.
Most are "the method threw
not implementedbut the caller was asking for nothing", or "the paint was built from the wrong geometry". Several have to land together: 1-4 are all required before the GUI ColorPicker renders at all, and 5-8 before a gradient-stroked GUI control renders at all.Correctness
ImageData.datareturned a fresh copy on every access, sogetImageData→ mutate →putImageDatasilently no-op'd. It is also now the specifiedUint8ClampedArray; a plainUint8Arraywrappeddata[i] = v + 40modulo 256, turning bright pixels darkclip()suppressedbeginPath()for every later draw, so each fill repainted the union of every rect since the clipdrawImage(img, dx, dy)anchored the pattern at(0,0)while drawing the rect at(dx,dy), so it drew nothing at any non-zero offset(x0,y0)→(x1,y1)discardedstrokeStyleaccepted only a color string; a gradient threwTypeError: A string was expectedand took the whole scene down (GUILine,Buttonborder)r0 == 0. Now solves the real two-circle equation. The tangent case (leading coefficient vanishes → linear solve) needs its own branch and is not exotic — the repro'screateRadialGradient(100,100,150, 250,100,300)lands there#ff0000ff → #00000000faded through maroon to black. Now premultiplied as specifiedfill()never bound the fill style, using whatever color nanovg happened to hold — never a gradient. Every path-filled control (Ellipse,Buttonbackground,Slider) filled solid whitecreateImageDatawas missing entirelyPath2Dwas registered only on_native, not as a global, soAbstractEngine.createCanvasPath2DthrewReferenceErroron every engine exceptThinNativeEngineloadTTFfailures surfaced as a bareError: Invalid argument, naming neither method nor argument. Now descriptive, and any ArrayBuffer view is accepted — so the usual real cause,Tools.LoadFileAsync(url)withoutuseArrayBuffer, is named directlyputImageDatawas an unconditional throw. Now writes through a transient nanovg image undernvgSave/nvgResetwithNVG_COPY, so it correctly ignores transform, clip,globalAlpha, compositing, shadows and filters and replaces rather than blendssetLineDashthrew — and GUILine/MultiLinecall it every render with_dashdefaulting to[], so any scene with a GUI line died on an empty dash list asking for nothing. Empty now means "solid", which is what we already drawrgba(0, 0, 0, 0.5)failed to parse. Now generic CSS numbers with optional%, plus the whitespace-separated and/ alphaforms andhsl()/hsla()— see the behaviour change belowshadowBlur/shadowOffset*to0right after drawing, so the throw hit the reset path too and any shadowed control failed outright. nanovg has no shadow primitive, but these are ordinary state attributes, so they are stored and reportedsave()/restore()snapshotted onlyfillStyle/strokeStyle, but the wrapper mirrors everything it reprojects or that nanovg cannot report back — solineCap,lineJoin,lineWidth,miterLimit, the dash list,filter,direction,letterSpacing,globalAlphaand the shadow attributes were never restored. The getters reported the post-save()value forever, and the shadow attributes, re-applied from the mirror on every draw, actually rendered wrong. All fifteen fields are now snapshottedstd::map::insert()discarded the second stop at an already-present offset, which is exactly how the spec encodes a hard transition, so a stripe pattern came out as a smooth fade. Now astd::multimapsetLineDash()cleared the list before validating, sosetLineDash([-1])aftersetLineDash([5,10])leftgetLineDash()returning[]Two smaller gradient bugs were fixed alongside 4-8: the ramp sample buffer was uninitialized (samples outside the stop range read stack garbage), and
gradientSpandivided by zero when two stops shared an offset.Robustness
Three crash classes found by the Copilot review, all reachable from ordinary script rather than only from a misbehaving embedder:
fillStyle = "rgb(999…999, 0, 0)",font = "18e999px Arial"andletterSpacing = "999…999px"madestd::stof/std::stoithrowstd::out_of_range. That is not aNapi::Error, so node-addon-api's wrapper did not catch it and it unwound out of the N-API boundary and terminated the process. Nowstrtof/strtol, with infinities folded to finite extremes so the existing clamps stay well defined (nvgHSLAtakesfmodfof the hue, andfmodf(inf, 1)is NaN)UpdateCache()dereferencedcontext.lock()three times unchecked, so the guard inPaint()ran after the crashputImageData(img, 0, 0, 0, 0, -2147483648, 1)normalized negative dirty extents inint32_t, makingdirtyX += dirtyWidthand-dirtyWidthsigned overflow on caller-controlled input. Nowint64_t, saturating back after clipping19 is confirmed by experiment, not inspection: built without the fix, the unit test binary stops dead inside the
Canvas2Dsuite with[Uncaught Error] Unknown failureand exit 1, taking the remaining ~20 tests with it. The pre-existing font guard covered onlystd::invalid_argument(the"normal"keyword), soout_of_rangestill went through.One intentional behaviour change — please look at this closely
The
parseColorunit test assertedrgba(16,32,48,64)→ alpha0x40, i.e. that alpha is a 0-255 channel like r/g/b. CSS Color defines alpha as 0-1 (or a percentage), so a browser clamps64to1and paints that colour opaque.That reading was not a deliberate extension — the old regex matched all four components as 1-3 digit integers and ran
std::stoiover them, so alpha simply inherited the channel treatment, and the test was written to describe what the implementation happened to do (it arrived with the impl in #1051). It is also the same root cause as bug 14: under a 0-255 alpha,rgba(0, 0, 0, 0.5)truncates to0and the colour vanishes. Alpha cannot be both, so the test now follows CSS, extended to cover fractional and percentage alpha, the/ alphaform, whitespace-separated and percentage components, andhsl()/hsla(). All ten malformed-input cases still throw.Deliberate limitations
clip()is approximated. nanovg's scissor is rectangle-only and no stencil path is available, so the choice is between approximating and throwing; this keeps the rectangular case exact and degrades the other rather than failing the scene.putImageData()clobbers the current path — as dofillRect,clearRect,drawImageandPath2Dplayback, none of which may touch it per spec. nanovg offers no local fix, sincenvgSave/nvgRestoreonly push and popNVGstatewhilenvgBeginPathclearsctx->ncommandsand the path cache. A real fix means journaling the path wrapper-side and replaying it; self-contained, and better as a follow-up.Validation
Full Playground validation sweep 304/304 PASS, no regressions;
JavaScript.Allunit tests 36/36.Three previously-excluded tests are re-enabled and now pass inside the default 2.5% budget:
GUI Gradient Linear(#XCPP9Y#17227)TypeError— never renderedGUI Gradient Radial(#4Z7EK3)GUI Gradient Linear with transparency(#PFK1Z5)The
GUItest went 13.5% → 3.9% (allowed 4%) across bugs 1-4 — 13.5% → 8.2% (clip) → 6.4% (drawImage origin) → 3.9% (gradients) — because its ColorPicker builds its wheel viagetImageData+ mutate +putImageData(1), composites it with a 3-argdrawImageat a non-zero offset (3), draws it and its saturation square under oneclip()(2), and paints that square with two overlaid linear gradients (4). Nine further tests newly pass across the same four fixes.Bugs 10 and 11 are test-neutral and were verified directly, and new
Canvas2Dunit tests cover the contract that used to throw: both style round-trips, aCanvasGradientasfillStyleand asstrokeStyle, a radial gradient from two independent circles, save/restore of a gradientstrokeStyleand of all fifteen state fields, dash retention on a rejected argument, coincident stops, and three regressions for the out-of-range parse crashes.One implementation note:
DataViewis read through itsbuffer/byteOffset/byteLengthproperties rather thanNapi::DataView, because Chakra's Node-API backsnapi_get_dataview_infowithJsGetExternalData, which only succeeds for natively created DataViews — a JS-constructed one fails withnapi_invalid_argeven thoughnapi_is_dataviewreturnstrue. That shim inconsistency is a separate JsRuntimeHost issue; this change just avoids depending on it.