From 6e50b1579eb8f93f72e5e2ef9af1f971a7cfbe5a Mon Sep 17 00:00:00 2001 From: Andrey Sidorov Date: Mon, 9 Nov 2026 22:38:02 +1100 Subject: [PATCH 1/3] feat(2d): globalCompositeOperation takes the blend modes, multiply to luminosity, where the server has RENDER 0.11 The context mapped only the Porter-Duff names and lighter to XRender operators and ignored the rest, so a blend mode read back as the op before and drew as it. RENDER 0.11 has all fifteen, and every X.Org server since 2009 answers 0.11. A name is taken only where the server did, since one below it raises BadPictOp after the call has returned: reading the name back is how a caller knows it will be drawn. The blend operators are mask-bounded: a transparent source leaves the destination as it was, and each is linear in the source, so a clip and globalAlpha folded into the mask come to what they would after the op. Needs an x11 whose in-process server composites them. --- docs/context-2d.md | 46 ++++- lib/renderingcontext_2d.js | 54 +++++- test/blend-modes.test.js | 361 +++++++++++++++++++++++++++++++++++++ 3 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 test/blend-modes.test.js diff --git a/docs/context-2d.md b/docs/context-2d.md index 5e7b6a8..6eb5047 100644 --- a/docs/context-2d.md +++ b/docs/context-2d.md @@ -72,15 +72,20 @@ ctx.fillRect(0, 0, 100, 100); - `ctx.shadowColor`, `ctx.shadowBlur`, `ctx.shadowOffsetX`, `ctx.shadowOffsetY` — drop shadows, off by default (a transparent `shadowColor`). See **Shadows** below -- `ctx.globalCompositeOperation` — Porter-Duff subset mapped to XRender ops: - `source-over` (default), `copy`, `destination-over`, `source-in`, +- `ctx.globalCompositeOperation` — mapped to XRender ops: the Porter-Duff + subset, `source-over` (default), `copy`, `destination-over`, `source-in`, `destination-in`, `source-out`, `destination-out`, `source-atop`, - `destination-atop`, `xor`, `lighter`. Everything drawn takes it: fills, + `destination-atop`, `xor`, `lighter`, and the blend modes, `multiply`, + `screen`, `overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, + `hard-light`, `soft-light`, `difference`, `exclusion`, `hue`, + `saturation`, `color` and `luminosity`. Everything drawn takes it: fills, strokes, rectangles, images, text (`fillText`, `TextLayout.draw`) and the shadows they cast. The ops that write where a drawing has no ink clear the rest of the drawing's own box, not the surface, and no op changes a pixel outside the clip — see - [Composite ops that clear](#composite-ops-that-clear) + [Composite ops that clear](#composite-ops-that-clear). A name the context + cannot draw is ignored, as a browser ignores one it does not know, so read + it back to know — see [Blend modes](#blend-modes) - `ctx.font` — the CSS `font` shorthand (`'bold italic 40px "DejaVu Sans"'`), resolved through fontconfig; see [fonts.md](fonts.md). Style, weight, `small-caps` and a stretch keyword come in any order before the size; a @@ -119,6 +124,39 @@ The ops that write where a drawing has no ink — `copy`, `source-in`, region the way every other op does, and a path clip after the op rather than through the mask: see [Composite ops that clear](#composite-ops-that-clear). +### Blend modes + +The blend modes are RENDER's from 0.11 (`Multiply` to `HSLLuminosity`), +which composite as CSS's `mix-blend-mode` does: what a pixel comes to is a +function of the colour drawn and the colour under it. A server that answered +`QueryVersion` below 0.11 has none of them, and raises `BadPictOp` for each +after the call has returned, so the context takes a blend mode's name only +where the server has them and leaves the op as it was otherwise. A caller +asks by setting one and reading it back: + +```js +ctx.globalCompositeOperation = 'multiply'; +if (ctx.globalCompositeOperation !== 'multiply') { + // this server cannot blend: draw it some other way +} +``` + +Every X.Org server since 2009 has them — Xorg, Xvfb, Xwayland, XQuartz — and +so does node-x11's in-process one. On Xvfb and XQuartz, and wherever the +server composites with fb, pixman draws them. glamor, under Xorg's +modesetting driver and Xwayland, has shaders for the Porter-Duff operators +alone, and falls back to software for these, reading what they are drawn +over back from the GPU: a large surface drawn with one costs more there than +one drawn `source-over`. + +They take the clip, `globalAlpha` and a drawing's antialiased edge as +`source-over` does, through the mask: a transparent source leaves the +destination as it was through every one of them, and each is linear in the +source, so a pixel half covered is half of the way to the blend. pixman runs +`color-dodge`, `color-burn`, `soft-light` and the four HSL modes in single +floats and the rest in 8-bit integers; the in-process server matches the +second to the bit and the first within a level. + ### Composite ops that clear `copy`, `source-in`, `destination-in`, `source-out` and `destination-atop` diff --git a/lib/renderingcontext_2d.js b/lib/renderingcontext_2d.js index c267dc9..05be75f 100755 --- a/lib/renderingcontext_2d.js +++ b/lib/renderingcontext_2d.js @@ -655,6 +655,30 @@ const GCO_TO_PICTOP = { lighter: "Add", }; +// The blend modes, which RENDER has from 0.11 (Multiply .. HSLLuminosity): +// what a pixel comes to is a function of both colours, as CSS's +// mix-blend-mode has it. A server that answers QueryVersion below 0.11 raises +// BadPictOp for every one of them, after the call has returned, so the setter +// takes one of these names only where the server has them (`_blendModes`). +// Reading the name back is how a caller asks whether it will be drawn. +const BLEND_TO_PICTOP = { + multiply: "Multiply", + screen: "Screen", + overlay: "Overlay", + darken: "Darken", + lighten: "Lighten", + "color-dodge": "ColorDodge", + "color-burn": "ColorBurn", + "hard-light": "HardLight", + "soft-light": "SoftLight", + difference: "Difference", + exclusion: "Exclusion", + hue: "HSLHue", + saturation: "HSLSaturation", + color: "HSLColor", + luminosity: "HSLLuminosity", +}; + // The ops whose result is the destination wherever the mask is zero. Only // those may have a drawing's mask split into several boxes (maskcluster.js): // what the split gives up is the gaps between the boxes, and for these ops @@ -663,6 +687,11 @@ const GCO_TO_PICTOP = { // the source — or nothing — across the whole box, so they keep one. Nor can // a path clip be folded into their mask: it is applied after them instead // (`_clipAfter`). +// +// Every blend mode is one of them: a transparent source leaves the +// destination as it was. And a clip folded into the mask comes to what it +// would after the op, since each is linear in the source — through a mask of +// a half, `multiply` is half of the way to `multiply`. const MASK_BOUNDED_OPS = [ "Over", "OverReverse", @@ -670,6 +699,7 @@ const MASK_BOUNDED_OPS = [ "Atop", "Xor", "Add", + ...Object.values(BLEND_TO_PICTOP), ]; // and what they are clustered with instead: one mask, exactly as before @@ -1297,11 +1327,31 @@ class RenderingContext2d { } _op() { - return this.Render.PictOp[GCO_TO_PICTOP[this._gco] || "Over"]; + const name = Object.hasOwn(GCO_TO_PICTOP, this._gco) + ? GCO_TO_PICTOP[this._gco] + : BLEND_TO_PICTOP[this._gco]; + return this.Render.PictOp[name]; } + /** + * Whether the server composites RENDER 0.11's blend operators: whether it + * answered QueryVersion with 0.11 or later. Every X.Org server since 2009 + * does — Xorg, Xvfb, Xwayland, XQuartz — and so does node-x11's + * in-process one, which composites them from the release this needs. + */ + _blendModes() { + const [major, minor] = this.Render.version || [0, 0]; + return major > 0 || minor >= 11; + } + + // An operator canvas names and this context cannot draw is ignored, as a + // browser ignores one it does not know: the name a caller reads back is + // the one that is drawn. set globalCompositeOperation(value) { - if (value in GCO_TO_PICTOP) this._gco = value; + if (Object.hasOwn(GCO_TO_PICTOP, value)) this._gco = value; + else if (Object.hasOwn(BLEND_TO_PICTOP, value) && this._blendModes()) { + this._gco = value; + } } get globalCompositeOperation() { diff --git a/test/blend-modes.test.js b/test/blend-modes.test.js new file mode 100644 index 0000000..a7bf551 --- /dev/null +++ b/test/blend-modes.test.js @@ -0,0 +1,361 @@ +// Canvas's blend modes, `multiply` to `luminosity`, are RENDER 0.11's blend +// operators. The context takes the names only where the server has them, so +// a caller that reads one back knows it will be drawn — CSS mix-blend-mode is +// drawn that way — and everything that draws composites with them: fills, +// strokes, text, images and a surface drawn whole. +// +// What a server draws for an opaque colour over an opaque one is the blend +// function of the two, which `OPAQUE` holds as Xvfb (pixman) drew it. pixman +// runs color-dodge, color-burn, soft-light and the four HSL modes in single +// floats, and node-x11's in-process server in doubles, so those can be a +// level apart: every comparison here allows one. +// +// Hermetic: node-x11's in-process pure-JS X server + the fixture font. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, test } from 'node:test'; + +import xserver from 'x11/lib/xserver/index.js'; + +import { createClient, StaticFontSource, Surface } from '../lib/index.js'; + +const VF = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'MonelogicsSubset[wght].ttf'); + +const W = 60; +const H = 40; +const BACKDROP = '#cc6633'; +const COLOUR = '#3366cc'; + +// COLOUR over BACKDROP, both opaque, as pixman composites each mode +const OPAQUE = { + multiply: [0x29, 0x29, 0x29], + screen: [0xd6, 0xa3, 0xd6], + overlay: [0xad, 0x52, 0x52], + darken: [0x33, 0x66, 0x33], + lighten: [0xcc, 0x66, 0xcc], + 'color-dodge': [0xff, 0xaa, 0xff], + 'color-burn': [0x00, 0x00, 0x00], + 'hard-light': [0x52, 0x52, 0xad], + 'soft-light': [0xb4, 0x5a, 0x59], + difference: [0x99, 0x00, 0x99], + exclusion: [0xad, 0x7a, 0xad], + hue: [0x50, 0x83, 0xe9], + saturation: [0xcc, 0x66, 0x33], + color: [0x50, 0x83, 0xe9], + luminosity: [0xaf, 0x49, 0x16] +}; +const BLENDS = Object.keys(OPAQUE); +const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + +let server = null; +let app = null; +const errors = []; + +before(async () => { + server = xserver.createServer({ width: 320, height: 240 }); + const [serverEnd, clientEnd] = xserver.createStreamPair(); + server.addClientStream(serverEnd); + const fontSource = new StaticFontSource(); + fontSource.add(readFileSync(VF), { family: 'Fixture' }); + app = await createClient({ + stream: clientEnd, + fontSource, + onXError: (err) => errors.push(err) + }); +}); + +after(async () => { + assert.deepEqual(errors, [], 'no request the server refused'); + if (app) await app.close(); +}); + +/** `draw` on a canvas of `backdrop`, read back as straight RGBA */ +async function paint(draw, { backdrop = BACKDROP, depth = 24 } = {}) { + const pixmap = app.createPixmap({ width: W, height: H, depth }); + const ctx = pixmap.getContext('2d'); + ctx.fillStyle = backdrop; + ctx.fillRect(0, 0, W, H); + draw(ctx); + const image = await ctx.getImageData(0, 0, W, H); + ctx.destroy(); + pixmap.destroy(); + return image.data; +} + +const at = (data, x, y) => [...data.slice((y * W + x) * 4, (y * W + x) * 4 + 3)]; + +function near(got, want, label, slack = 1) { + const off = got.some((v, i) => Math.abs(v - want[i]) > slack); + assert.ok(!off, `${label}: ${JSON.stringify(got)}, not within ${slack} of ${JSON.stringify(want)}`); +} + +/** with the server's RENDER version reading as `version` for the while */ +function withRenderVersion(version, fn) { + const R = app.display.Render; + const was = R.version; + R.version = version; + try { + return fn(); + } finally { + R.version = was; + } +} + +describe('the names', () => { + test('every blend mode is taken where the server has RENDER 0.11', () => { + const pixmap = app.createPixmap({ width: 4, height: 4, depth: 24 }); + const ctx = pixmap.getContext('2d'); + for (const mode of BLENDS) { + ctx.globalCompositeOperation = 'source-over'; + ctx.globalCompositeOperation = mode; + assert.equal(ctx.globalCompositeOperation, mode); + } + ctx.destroy(); + pixmap.destroy(); + }); + + test('below 0.11 none of them is, and the op stays what it was', () => { + const pixmap = app.createPixmap({ width: 4, height: 4, depth: 24 }); + const ctx = pixmap.getContext('2d'); + withRenderVersion([0, 10], () => { + ctx.globalCompositeOperation = 'xor'; + for (const mode of BLENDS) { + ctx.globalCompositeOperation = mode; + assert.equal(ctx.globalCompositeOperation, 'xor', mode); + } + // and the Porter-Duff ones still are + ctx.globalCompositeOperation = 'lighter'; + assert.equal(ctx.globalCompositeOperation, 'lighter'); + }); + ctx.destroy(); + pixmap.destroy(); + }); + + test('a name canvas does not have is ignored, an inherited one too', () => { + const pixmap = app.createPixmap({ width: 4, height: 4, depth: 24 }); + const ctx = pixmap.getContext('2d'); + ctx.globalCompositeOperation = 'multiply'; + for (const name of ['normal', 'Multiply', 'toString', 'constructor', '', null]) { + ctx.globalCompositeOperation = name; + assert.equal(ctx.globalCompositeOperation, 'multiply', String(name)); + } + ctx.destroy(); + pixmap.destroy(); + }); + + test('save() and restore() keep a blend mode as they keep any op', () => { + const pixmap = app.createPixmap({ width: 4, height: 4, depth: 24 }); + const ctx = pixmap.getContext('2d'); + ctx.globalCompositeOperation = 'screen'; + ctx.save(); + ctx.globalCompositeOperation = 'difference'; + ctx.restore(); + assert.equal(ctx.globalCompositeOperation, 'screen'); + ctx.destroy(); + pixmap.destroy(); + }); +}); + +describe('the pixels', () => { + for (const mode of BLENDS) { + test(`${mode}: an opaque colour over an opaque one`, async () => { + const data = await paint((ctx) => { + ctx.globalCompositeOperation = mode; + ctx.fillStyle = COLOUR; + ctx.fillRect(10, 10, 20, 20); + }); + near(at(data, 20, 20), OPAQUE[mode], mode); + assert.deepEqual(at(data, 5, 5), rgb(BACKDROP), `${mode}: outside the fill`); + }); + } + + test('a gray pill multiplied over a word leaves the word black, and screen leaves it white', async () => { + // vercel.com's "New" badges: an absolutely positioned pill painted after + // the word it marks, multiply in the light theme and screen in the dark + const light = await paint( + (ctx) => { + ctx.fillStyle = '#000'; + ctx.fillRect(10, 15, 20, 10); // the word + ctx.globalCompositeOperation = 'multiply'; + ctx.fillStyle = '#eaeaea'; + ctx.fillRect(5, 10, 40, 20); // the pill + }, + { backdrop: '#fff' } + ); + assert.deepEqual(at(light, 20, 20), [0, 0, 0], 'the word under the pill'); + assert.deepEqual(at(light, 7, 20), [0xea, 0xea, 0xea], 'the pill beside it'); + assert.deepEqual(at(light, 50, 20), [0xff, 0xff, 0xff], 'the page'); + + const dark = await paint( + (ctx) => { + ctx.fillStyle = '#fff'; + ctx.fillRect(10, 15, 20, 10); + ctx.globalCompositeOperation = 'screen'; + ctx.fillStyle = '#333'; + ctx.fillRect(5, 10, 40, 20); + }, + { backdrop: '#000' } + ); + assert.deepEqual(at(dark, 20, 20), [0xff, 0xff, 0xff], 'the word under the pill'); + assert.deepEqual(at(dark, 7, 20), [0x33, 0x33, 0x33], 'the pill beside it'); + assert.deepEqual(at(dark, 50, 20), [0, 0, 0], 'the page'); + }); + + // Each way a drawing reaches the surface, `mode` on the context, and a + // point it covers whole. What lands there is the blend of COLOUR and the + // backdrop, or under `alpha` that far towards it from the backdrop. + const ROUTES = { + fillRect: (ctx) => { + ctx.fillStyle = COLOUR; + ctx.fillRect(10, 8, 40, 24); + }, + 'fillRect off the pixel grid': (ctx) => { + ctx.fillStyle = COLOUR; + ctx.fillRect(10.4, 8.6, 40, 24); + }, + fillRects: (ctx) => { + ctx.fillStyle = COLOUR; + ctx.fillRects([ + [10, 8, 15, 24], + [25, 8, 25, 24] + ]); + }, + fill: (ctx) => { + ctx.fillStyle = COLOUR; + ctx.beginPath(); + ctx.arc(30, 20, 16, 0, Math.PI * 2); + ctx.fill(); + }, + stroke: (ctx) => { + ctx.strokeStyle = COLOUR; + ctx.lineWidth = 10; + ctx.beginPath(); + ctx.moveTo(4, 20); + ctx.lineTo(56, 20); + ctx.stroke(); + }, + 'a surface drawn whole': (ctx) => { + const group = new Surface(app, { width: 40, height: 24 }); + group.render((g) => { + g.fillStyle = COLOUR; + g.fillRect(0, 0, 40, 24); + }); + ctx.drawImage(group, 10, 8); + group.destroy(); + }, + 'a surface drawn scaled': (ctx) => { + const group = new Surface(app, { width: 20, height: 12 }); + group.render((g) => { + g.fillStyle = COLOUR; + g.fillRect(0, 0, 20, 12); + }); + ctx.drawImage(group, 10, 8, 40, 24); + group.destroy(); + }, + 'a surface drawn turned': (ctx) => { + const group = new Surface(app, { width: 40, height: 24 }); + group.render((g) => { + g.fillStyle = COLOUR; + g.fillRect(0, 0, 40, 24); + }); + ctx.translate(30, 20); + ctx.rotate(0.3); + ctx.drawImage(group, -20, -12); + group.destroy(); + } + }; + const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t); + + for (const [route, draw] of Object.entries(ROUTES)) { + for (const mode of ['multiply', 'screen', 'hue']) { + test(`${route}, ${mode}`, async () => { + const plain = await paint((ctx) => { + ctx.globalCompositeOperation = mode; + draw(ctx); + }); + near(at(plain, 30, 20), OPAQUE[mode], `${route}, ${mode}`); + assert.deepEqual(at(plain, 1, 1), rgb(BACKDROP), `${route}, ${mode}: away from the drawing`); + + const faded = await paint((ctx) => { + ctx.globalCompositeOperation = mode; + ctx.globalAlpha = 0.5; + draw(ctx); + }); + near(at(faded, 30, 20), lerp(rgb(BACKDROP), OPAQUE[mode], 0.5), `${route}, ${mode}, at half alpha`, 2); + + // under a path clip, folded into the drawing's mask: inside it the + // blend, outside it the backdrop, as after the op + const clipped = await paint((ctx) => { + ctx.beginPath(); + ctx.rect(26, 0, 30, H); + ctx.arc(26, 20, 6, 0, Math.PI * 2); + ctx.clip(); + ctx.globalCompositeOperation = mode; + draw(ctx); + }); + near(at(clipped, 30, 20), OPAQUE[mode], `${route}, ${mode}, inside a path clip`); + assert.deepEqual(at(clipped, 14, 20), rgb(BACKDROP), `${route}, ${mode}: outside the clip`); + }); + } + } + + test('text takes a blend mode', async () => { + const data = await paint((ctx) => { + ctx.globalCompositeOperation = 'multiply'; + ctx.fillStyle = '#000'; + ctx.font = '30px Fixture'; + ctx.fillText('HH', 4, 32); + }); + const ink = []; + for (let x = 0; x < W; x++) ink.push(at(data, x, 20)); + // black multiplied over anything is black, and the rest is the backdrop + assert.ok(ink.some((p) => p.every((v) => v === 0)), 'some of the letters is black'); + assert.ok( + ink.every((p) => p.every((v, i) => v <= rgb(BACKDROP)[i])), + 'nothing multiplied comes out lighter than the backdrop' + ); + }); + + test('a path of scattered pieces is masked as its pieces, as for source-over', () => { + // a blend mode leaves the destination as it was where the mask is zero, + // so the gaps between the pieces need no mask (maskcluster.js) + const stats = {}; + for (const mode of ['source-over', ...BLENDS]) { + const pixmap = app.createPixmap({ width: 300, height: 200, depth: 24 }); + const ctx = pixmap.getContext('2d'); + ctx.globalCompositeOperation = mode; + ctx.fillStyle = COLOUR; + ctx.beginPath(); + ctx.arc(10, 10, 6, 0, Math.PI * 2); + ctx.closePath(); + ctx.moveTo(291, 185); + ctx.arc(285, 185, 6, 0, Math.PI * 2); + ctx.closePath(); + ctx.fill(); + stats[mode] = { ...ctx.maskStats }; + ctx.destroy(); + pixmap.destroy(); + } + assert.equal(stats['source-over'].split, 1, 'source-over splits the mask'); + for (const mode of BLENDS) assert.deepEqual(stats[mode], stats['source-over'], mode); + }); + + test('onto a surface with alpha, a blend over transparency is the colour', async () => { + // over a transparent backdrop every blend mode is the source itself + const surface = new Surface(app, { width: 8, height: 8 }); + const ctx = surface.getContext('2d'); + for (const [i, mode] of ['multiply', 'difference', 'luminosity'].entries()) { + ctx.globalCompositeOperation = mode; + ctx.fillStyle = COLOUR; + ctx.fillRect(i * 2, 0, 2, 8); + } + const data = await ctx.getImageData(0, 0, 8, 8); + for (let x = 0; x < 6; x++) { + assert.deepEqual([...data.data.slice(x * 4, x * 4 + 4)], [...rgb(COLOUR), 255], `column ${x}`); + } + ctx.destroy(); + surface.destroy(); + }); +}); From 388999ffb8783028400ea513d0a8c8ed06af710b Mon Sep 17 00:00:00 2001 From: Andrey Sidorov Date: Mon, 9 Nov 2026 22:46:28 +1100 Subject: [PATCH 2/3] docs(2d): older pixman masks the HSL blend modes wrongly --- docs/context-2d.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/context-2d.md b/docs/context-2d.md index 6eb5047..8b2a703 100644 --- a/docs/context-2d.md +++ b/docs/context-2d.md @@ -157,6 +157,14 @@ source, so a pixel half covered is half of the way to the blend. pixman runs floats and the rest in 8-bit integers; the in-process server matches the second to the bit and the first within a level. +Older pixman draws the four HSL modes wrongly through a mask: Debian +bookworm's, 0.42.2, scales green by the mask twice and blue not at all, +where 0.46.4 is right. So on a server built on it, `hue`, `saturation`, +`color` and `luminosity` come out off in green and blue wherever there is a +mask: under `globalAlpha` below 1, a path clip, and the antialiased edge of +anything but a whole-pixel rectangle or image. Nothing on the client can +tell or correct it. + ### Composite ops that clear `copy`, `source-in`, `destination-in`, `source-out` and `destination-atop` From 204a19f6bcded9b9e9f048442dce7e50ef8963b0 Mon Sep 17 00:00:00 2001 From: Andrey Sidorov Date: Mon, 9 Nov 2026 23:20:05 +1100 Subject: [PATCH 3/3] build(deps): x11 ^4.4.0, whose in-process server composites the blend operators --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25ab797..f7b1d39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0", - "x11": "^4.1.0" + "x11": "^4.4.0" }, "devDependencies": { "@conventional-commits/parser": "^0.4.1", @@ -420,9 +420,9 @@ } }, "node_modules/x11": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/x11/-/x11-4.3.3.tgz", - "integrity": "sha512-scSNghvfgr4ak9tLt3jEAeldE0U641nyLfPPy17he5FrLKCLdSyvWoaTyCFHRPWZ5qldrEe8jeMNTAqm0aKWDg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/x11/-/x11-4.4.0.tgz", + "integrity": "sha512-xqIUMvrWJawtd8pQgD85t0sUnBbk94Bd21Gevn0vPYvRH0hkyP3CQjeT85aJEw+ZK/txO4mIwj1P9LM1+mhuJA==", "engines": { "node": ">=18" } diff --git a/package.json b/package.json index fcf3013..c744432 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0", - "x11": "^4.1.0" + "x11": "^4.4.0" }, "optionalDependencies": { "x11-dri": ">=0.5.0 <1"