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 16 commits into
Conversation
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.
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 |
|
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
|
| // 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) |
There was a problem hiding this comment.
roundRect() + clip() misses this branch and falls through to the scissor below, clipping to m_rectangleClipping — which RoundRect sets to the plain bounding box (:502) without ever setting m_pathHasNonRect. The radii are dropped and the corners come out square. A rounded rect is exactly the kind of shape nvgScissor cannot express, which is what this flag's own comment says it tracks.
Pre-existing rather than a regression, but the flag is new here and this is the case that slips through it.
There was a problem hiding this comment.
You're right that roundRect never sets the flag, and I set it -- then the visual tests said no.
Routing roundRect into the emulation branch makes the output substantially worse. The emulation leaves the path current so the next fill draws it, and nanovg fills the union of the subpaths rather than their intersection, so roundRect(); clip(); fillRect() paints the whole fillRect instead of the rounded region. Measured on the Native Canvas visual test:
roundRect sets m_pathHasNonRect |
pixel difference |
|---|---|
| no (current) | 1.850% |
| yes | 20.980% |
I also gated it on nanovg's own 0.1 radius threshold first, in case near-zero radii were the problem. Same result.
So dropping the radii is wrong, but it is the far smaller of the two errors, and the emulation cannot be made right by feeding more shapes into it -- a correct clip() needs real path clipping (a stencil pass in nanovg). I've left roundRect on the scissor and put the measurement and the reasoning in a comment at the end of RoundRect so the next reader doesn't repeat the experiment. Happy to open a follow-up issue for the stencil-based clip if you'd like it tracked.
| float miterLimit; | ||
| float lineWidth; | ||
| float globalAlpha; | ||
| float letterSpacing; |
There was a problem hiding this comment.
font is the one exposed attribute left out. SetFont writes m_font and m_currentFontId (Context.cpp:1691-1698) and GetFont reads m_font, so it is the same wrapper-side mirror as the fields here, and per spec restore() rewinds it. It is also worse than the getter-only cases: m_currentFontId is what the text draw path binds, so the wrong font actually renders after a restore.
Holding this state as a single State struct on the class — with save/restore a push/pop of it — would make a forgotten field impossible rather than unlikely.
There was a problem hiding this comment.
Fixed in d690deb -- SavedStyle now carries font and currentFontId, and Restore rewinds both. Confirmed it was the real-rendering case you describe and not just a getter: with only Save updated the test still failed on the bound face.
New test restores font, which fails without the fix:
AssertionError: expected '40px "Times"' to equal '18px "Arial"'
(It compares against the round-tripped value rather than the assigned literal, since the getter reports a normalized serialization.)
On folding the whole thing into a single State struct that save/restore push and pop -- I agree, and it's the right shape; a forgotten field is exactly how this one got missed. I'd rather not do it in this PR: it touches every getter and setter on Context, which would bury the behavior fixes here in a rename diff. Happy to do it as the immediate follow-up, or here if you'd prefer it not wait.
| if (!m_lineDash.empty()) | ||
| { | ||
| static bool warned = false; | ||
| if (!warned) | ||
| { | ||
| warned = true; | ||
| fprintf(stderr, "Context2D.setLineDash: dashed strokes are not supported; drawing solid.\n"); | ||
| } | ||
| } |
There was a problem hiding this comment.
These warnings go to stderr, which on Android and iOS reaches nobody by default — the platforms where a silently-degraded canvas is hardest to diagnose.
JsConsoleLogger::LogWarn(info.Env(), …) calls console.warn, needs nothing but the Napi::Env already in hand, and lands where an app developer will see it. It lives in Plugins/NativeEngine/Source/ today, so this means relocating it; JsRuntimeHost's Core/JsRuntime fits — Canvas already links it through JsRuntimeInternal, it exposes napi publicly, and JsRuntimeHost owns the Console polyfill that provides the console being called. That makes it a JsRuntimeHost change plus a GIT_TAG bump here.
The shadow warning in WarnShadowUnsupported needs the same treatment, plus an Napi::Env parameter — it takes none today, and its three callers all have info.
There was a problem hiding this comment.
Agreed on all of it -- stderr is the wrong sink here, and JsConsoleLogger::LogWarn with the Napi::Env already in hand is the right one.
I'd like to do it as a separate PR rather than in this one. As you laid out it isn't a local change: JsConsoleLogger has to move from Plugins/NativeEngine/Source/ into JsRuntimeHost's Core/JsRuntime, which is a JsRuntimeHost PR, and then this repo needs a GIT_TAG bump before either call site can use it. That sequencing means it can't land atomically with the fixes here, and I'd rather not hold them behind it.
WarnShadowUnsupported gaining an Napi::Env parameter belongs in that same change -- all three callers have info, so it's mechanical once the logger is reachable.
I'll open the JsRuntimeHost PR next and link it here. Say the word if you'd rather this PR wait for it instead.
| if (global.Get(JS_PATH2D_CONSTRUCTOR_NAME).IsUndefined()) | ||
| { | ||
| global.Set(JS_PATH2D_CONSTRUCTOR_NAME, func); | ||
| } |
There was a problem hiding this comment.
When a global Path2D already exists this keeps the foreign one, and Context::Fill (315-317) unwraps whatever object it is handed as a NativeCanvasPath2D. So the branch that preserves someone else's constructor is the branch that guarantees ctx.fill(new Path2D(d)) unwraps an object that was never wrapped.
Canvas::Initialize calls this at Canvas.cpp:311 while the polyfill is still installing, so a global found here came from outside it.
Claiming the global unconditionally is the fix: a foreign Path2D cannot be drawn by this Context under any circumstances, so deferring to it only guarantees the failure. Verifying the object in Fill/Stroke is worth doing as well, but that converts undefined behaviour into a clean error rather than making the call work.
There was a problem hiding this comment.
Agreed and fixed in d690deb -- the global is now claimed unconditionally, with your reasoning recorded in the comment: a foreign Path2D cannot be drawn by this Context under any circumstances, so the branch that preserved it was the branch that guaranteed the bad unwrap.
Verifying the object in Fill/Stroke is worth doing too, and I'd like to take it with the CanvasGradient::IsInstance pattern from the other thread rather than bolt on a second one-off check -- happy to add it here if you'd rather not split it.
…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
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
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
… 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.
Addresses review feedback on the Canvas polyfill. Four behavior fixes, each
with a unit test that fails without it:
clip() emulates a non-rectangular path by leaving it current and letting the
next fill draw it, so any operation that resets the path invalidates that
emulation. ClearRect, the three DrawImage overloads and PutImageData reset the
path but left m_isClipped and m_pathHasNonRect set, so FillRect would skip its
own nvgBeginPath and append to a path that no longer exists, and a later clip()
would take the emulated branch on what is now a plain rect. They now go through
ResetPathState(), which does all three together.
font was the one exposed attribute missing from the saved state. It is worse
than the getter-only cases: m_currentFontId is what the text draw path binds,
so the wrong face actually rendered after a restore(). nvgRestore rewinds the
size it set, but neither of these.
fillStyle and strokeStyle reached CanvasGradient::Unwrap for any object, and
napi_unwrap dereferences whatever the object's slot holds -- ctx.fillStyle={}
is reachable from script. Both setters now test CanvasGradient::IsInstance and
leave the attribute unchanged otherwise, which is what the spec requires for a
value that is neither a color string nor a gradient/pattern.
setLineDash committed its empty temporary when the argument was present but not
a list, so setLineDash("x") cleared a list that setLineDash([-1]) correctly
kept. A rejected call now returns early. An absent argument still means solid.
Path2D claims the global unconditionally. A Path2D from any other constructor
cannot be drawn by this Context -- Context::Fill unwraps whatever object it is
handed as a NativeCanvasPath2D -- so deferring to a foreign one only guaranteed
that ctx.fill(new Path2D(d)) would unwrap an object that was never wrapped.
roundRect was reported as missing the same m_pathHasNonRect that the other
curved primitives set. It is, but routing it into the emulation makes things
worse, not better: nanovg fills the union of the subpaths rather than their
intersection, so
oundRect(); clip(); fillRect() paints the whole fillRect.
On the "Native Canvas" visual test that takes the pixel difference from 1.850%
to 20.980%. roundRect therefore keeps the scissor and a comment now records
why, along with what a real fix would need.
Also inlines ParseCreateImageDataArgs into its only caller and moves the
premultiplied-space comment to gradientSpan, where lerpColor's "See
gradientSpan" reference now resolves.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
loadTTFAsync delegated to LoadTTF, so every diagnostic it raised named Canvas.loadTTF -- a method the caller never invoked. Both entry points now share LoadTTFCore and pass their own name. The bad-font-data message also named Tools.LoadFileAsync and its signature. This layer cannot see how the data was fetched, and Tools is a Babylon.js API the polyfill has no dependency on, so the advice is wrong as often as it is right. It now states the problem in terms this layer can actually observe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
bd40ea2 to
21ac522
Compare
|
Pushed d690deb + 21ac522, rebased onto master ( All twelve threads answered. Ten are fixed; two are answered without a code change and say why:
Four new unit tests, each verified to fail with the fix reverted and pass with it:
Validation on Win32 D3D11:
One thing worth flagging that no thread raised: this PR moves |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
Polyfills/Canvas/Source/Context.cpp:425
ResetPathState()clears the new flags but leavesm_rectangleClippingdescribing the previous path. Consequently, afterrect(A); beginPath(); clip(),Clip()reuses A even thoughbeginPath()discarded it. Clear or explicitly invalidate the rectangle candidate here, and haveClip()distinguish an empty current path instead of falling back to stale geometry/full-canvas clipping.
m_isClipped = false;
m_pathHasNonRect = false;
Polyfills/Canvas/Source/Context.cpp:1499
- Canvas 2D requires an odd-length dash list to be repeated so the stored list has even length. As written,
setLineDash([7, 8, 9])makesgetLineDash()return[7, 8, 9]rather than[7, 8, 9, 7, 8, 9]. Normalizeparsedbefore committing it.
m_lineDash = std::move(parsed);
Polyfills/Canvas/Source/Colors.h:229
- Using independently interchangeable separators accepts invalid CSS colors, such as
rgb(1, 2 3)andrgb(1, 2, 3 / .5). Canvas style setters must ignore these malformed values, but this parser now treats them as valid and changes the paint. Parse the legacy comma grammar and modern space/slash grammar as separate alternatives so they cannot be mixed.
static const std::string separator{R"((?:\s*,\s*|\s+))"};
static const std::string alphaSeparator{R"((?:\s*,\s*|\s*/\s*))"};
static const std::string components{
R"(\(\s*)" + number + separator + number + separator + number +
"(?:" + alphaSeparator + number + R"()?\s*\))"};
Polyfills/Canvas/Source/Gradient.cpp:433
- This maps the gradient from its raw coordinates and lets
nvgFillPaintapply the transform active at draw time. Canvas gradients instead capture the current transform whencreateLinearGradient/createRadialGradientis called. If the transform changes between creation and painting, both endpoints (and the radial box below) are therefore rendered in the wrong coordinate space. Capture the creation-time transform inCanvasGradientand use it when building the paint.
return nvgImagePattern(*nvg, x0, y0, length, length, std::atan2(dy, dx), cachedImage, 1.f);
| else if (CanvasGradient::IsInstance(info.Env(), value)) | ||
| { | ||
| m_strokeStyle = CanvasGradient::Unwrap(value.As<Napi::Object>()); |
There was a problem hiding this comment.
Good catch, and it reproduces. I instrumented ~CanvasGradient and the bind path and ran a test that creates the gradient in a scope holding no reference to it, then forces a collection:
~CanvasGradient this=0000021D4B9DA240
BindFillStyle deref this=0000021D4B9DA240
Same address, destroyed and then dereferenced. It did not crash, which is why it went unnoticed -- the freed memory simply had not been reused yet. CanvasGradient is an ObjectWrap, so the native instance is owned by the JS wrapper and deleted from its finalizer, and ctx.fillStyle = ctx.createLinearGradient(...) leaves nothing rooting that wrapper.
Fixed in 4201348 by storing the assigned object in a persistent reference rather than a raw pointer. SavedStyle copies the styles on save()/restore() and Napi::ObjectReference is move-only, so it is held through a shared_ptr and the copies share one strong reference.
I also took your point about the getter. It now returns the gradient object instead of a Napi::External wrapping the raw pointer, which is what the spec asks for and makes otherCtx.fillStyle = ctx.fillStyle round-trip -- previously the getter handed back an External that the setter would then reject.
Two tests cover it, one for fillStyle and one for strokeStyle. Each tags the gradient with an expando, drops every reference, collects, and then checks the tag survives and the native gradient still accepts addColorStop. Both fail with the change reverted and pass with it. Full suite is 21/21 and the visual sweep is 305/305.
fillStyle and strokeStyle stored a bare CanvasGradient*. CanvasGradient is an
ObjectWrap, so the native instance is owned by its JavaScript wrapper and deleted
from the wrapper's finalizer. The common spelling
ctx.fillStyle = ctx.createLinearGradient(0, 0, w, 0);
leaves no reference to that wrapper anywhere, so the collector is free to run the
finalizer while the style still points at the object. Instrumenting the destructor
and the bind path shows exactly that ordering, the same address destroyed and then
dereferenced:
~CanvasGradient this=0000021D4B9DA240
BindFillStyle deref this=0000021D4B9DA240
Store the assigned JavaScript object in a persistent reference instead. SavedStyle
copies the styles on save()/restore() and Napi::ObjectReference is move-only, so the
reference is held through a shared_ptr and the copies share one strong reference.
The getters now return that object rather than a Napi::External wrapping the raw
pointer, which is what the spec asks for and makes otherCtx.fillStyle = ctx.fillStyle
round-trip. Previously the getter handed back an External that the setter would then
reject, and an earlier revision of the setter would have unwrapped it as if it were a
gradient.
Both new tests fail without this change and pass with it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
da6cf2f to
4201348
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
Polyfills/Canvas/Source/Gradient.cpp:433
- The pattern is transformed when
nvgFillPaint/nvgStrokePaintis called, so these raw endpoints use the transform active at draw time. Canvas gradients instead capture the current coordinate space when they are created; creating a gradient underscale(2, 2), resetting the transform, and then filling should retain the scaled endpoints, but this implementation does not. Capture the creation transform and bake/apply it to both linear and radial geometry before binding the paint.
return nvgImagePattern(*nvg, x0, y0, length, length, std::atan2(dy, dx), cachedImage, 1.f);
Polyfills/Canvas/Source/Context.cpp:1501
- Canvas normalizes an odd-length dash list by concatenating it with itself. Committing
parseddirectly makessetLineDash([7, 8, 9]); getLineDash()return[7, 8, 9]rather than[7, 8, 9, 7, 8, 9], so the newly added round-trip API is observably incorrect.
m_lineDash = std::move(parsed);
Polyfills/Canvas/Source/Context.cpp:247
- This getter enables the documented
otherCtx.fillStyle = ctx.fillStyleround trip, but the gradient image is cached in the creator context. The destination context then tries to bind that image handle through its own NanoVG texture table, where it is not registered, so cross-context gradient assignment renders no gradient (and also stops working once the creator is disposed). Cache/bake the ramp for the context that is drawing it rather than permanently binding a gradient to its creator.
// Return the gradient object that was assigned, as the spec requires, so that
// `otherCtx.fillStyle = ctx.fillStyle` round-trips.
return std::get<GradientStyle>(m_fillStyle)->Value();
Polyfills/Canvas/Source/Context.cpp:884
putImageDatahas 3-argument and 7-argument overloads. Calls with 4–6 arguments match neither overload and must throw, but this validation accepts them and the dirty-rectangle branch silently ignores the supplied extra arguments, writing the full image instead. Reject the incomplete dirty-rectangle form explicitly.
if (info.Length() < 3 || !info[0].IsObject())
{
throw Napi::TypeError::New(env, "Context2D.putImageData requires at least 3 arguments (imageData, dx, dy).");
}
The (imagedata) overload is duck-typed rather than restricted to a real ImageData,
so its width and height never went through WebIDL's unsigned long conversion. They
were read with Uint32Value(), which wraps: -1 became 4294967295 and 5e9 became
705032704. Both clear the width*height overflow check on 64-bit, so an invalid
source reached the allocation instead of being rejected. Measured on the old code:
{width:-1, height:1} -> attempts ~17 GB, fails with "Invalid function argument"
{width:5e9, height:1} -> attempts ~2.8 GB, same
{width:1.5, height:1} -> silently accepted as 1x1
The first two only surface as an error because the allocation happens to fail; a
wrapped extent small enough to allocate would succeed and hand back a buffer whose
dimensions bear no relation to what was asked for.
Read the dimensions as doubles and require a finite non-negative integer that fits
in uint32_t before converting. The (width, height) overload above already handled
its own wrap, and the drawImage ImageBitmap path is covered by its uint16_t extent
clamp, so this was the one place left.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
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.