ntk 8.8.0 → 8.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gl.js CHANGED
@@ -44,10 +44,13 @@ import { nodeRequire } from './builtin.js';
44
44
  * Linux only, macOS needs no device node.
45
45
  * - `GL_REMOTE_DISPLAY` — a TCP or forwarded display; both flavors are
46
46
  * local-only by construction.
47
- * - `GL_NO_FD_PASSING` — the display is local, but this JavaScript runtime
48
- * cannot send a descriptor over the socket. Bun is the case in the field;
49
- * x11 does it through a Node internal Bun does not implement. Linux only —
50
- * Apple-DRI passes no descriptors.
47
+ * - `GL_NO_FD_PASSING` — the display is local, but this connection cannot
48
+ * send a descriptor over the socket. x11 has a transport for each runtime
49
+ * it supports — `process.binding('pipe_wrap')` under Node, `bun:ffi`
50
+ * calling `sendmsg(2)` under Bun since x11 4.1.0 — so what is left here is
51
+ * an x11 below that under Bun, a runtime with neither path (Deno), or a
52
+ * transport that would not initialise. Linux only — Apple-DRI passes no
53
+ * descriptors.
51
54
  * - `GL_NO_DRI3` — the server has no DRI3/Present (Xvfb, Xephyr, XQuartz —
52
55
  * though XQuartz has its own path, see `GL_NO_APPLEDRI`).
53
56
  * - `GL_NO_APPLEDRI` — macOS, and the server has no Apple-DRI extension:
@@ -418,17 +421,20 @@ export function glCapabilities(app) {
418
421
 
419
422
  if (!canPassDescriptors(app.display)) {
420
423
  // The socket is local; what is missing is the ability to send a
421
- // descriptor along it. x11 does that through Node's internal
422
- // `process.binding('pipe_wrap')`, so a runtime that does not implement
423
- // it lands here — Bun, today. Saying "not a local socket" would send
424
- // the reader off to check their DISPLAY, which is fine.
424
+ // descriptor along it. x11 carries one through Node's internal
425
+ // `process.binding('pipe_wrap')`, and under Bun through `bun:ffi`
426
+ // calling `sendmsg(2)` — the latter since x11 4.1.0, so an x11 below
427
+ // that under Bun lands here, as does a runtime with neither path and a
428
+ // transport that would not initialise. Saying "not a local socket"
429
+ // would send the reader off to check their DISPLAY, which is fine.
425
430
  return fail(
426
431
  GLError.NO_FD_PASSING,
427
- `this ${runtimeName()} cannot pass file descriptors over the X socket, and DRI3 works by passing one`,
428
- 'The connection is local; the runtime is what cannot carry the descriptor.\n' +
429
- "x11 sends one through Node's process.binding('pipe_wrap'), which Bun does\n" +
430
- 'not implement. Run under Node for direct rendering, or leave glPolicy at its\n' +
431
- 'default and use indirect GLX, which needs no descriptor passing at all.'
432
+ `this connection cannot pass file descriptors over the X socket under ${runtimeName()}, and DRI3 works by passing one`,
433
+ 'The connection is local; the descriptor transport is what is missing.\n' +
434
+ "x11 carries one through Node's process.binding('pipe_wrap') under Node, and\n" +
435
+ 'through bun:ffi sendmsg(2) under Bun from x11 4.1.0 on. Under Bun on an older\n' +
436
+ 'x11, `npm install x11@^4.1.0` is the whole fix. Otherwise leave glPolicy at\n' +
437
+ 'its default and use indirect GLX, which needs no descriptor passing at all.'
432
438
  );
433
439
  }
434
440
 
@@ -55,7 +55,9 @@ import {
55
55
  drawGlyphRuns,
56
56
  encodeGlyphItems,
57
57
  positionedRunsInk,
58
+ roundFrom,
58
59
  runId,
60
+ snapOrigin,
59
61
  } from "./text/glyphs.js";
60
62
  import { TextLayout } from "./text/layout.js";
61
63
  import { reorderRuns } from "./text/shape.js";
@@ -1365,6 +1367,12 @@ class RenderingContext2d {
1365
1367
  * whole pixel inside the surface, and the composite carries it to wherever
1366
1368
  * the text is. Glyph origins are rounded to whole pixels on the way to the
1367
1369
  * server anyway, so nothing is lost by it.
1370
+ *
1371
+ * Where it lands is rounded the way `positionGlyphs` rounds a glyph
1372
+ * (`roundFrom`), so the shadow moves with its text: drawn whole pixels
1373
+ * away, it lands exactly that many pixels away, and a scroll blit can copy
1374
+ * it (issue #350). Rounded as one floating-point sum, an anchor on a half
1375
+ * pixel went down at x 104 and up at x 152.
1368
1376
  */
1369
1377
  _shadowOfText(text, x, y) {
1370
1378
  const app = this.window.app;
@@ -1435,8 +1443,8 @@ class RenderingContext2d {
1435
1443
  if (!surface) return;
1436
1444
  this._paintShadow(
1437
1445
  surface,
1438
- Math.round(runX + this._shadowOffsetX) - originX,
1439
- Math.round(runY + this._shadowOffsetY) - originY,
1446
+ roundFrom(runX, this._shadowOffsetX) - originX,
1447
+ roundFrom(runY, this._shadowOffsetY) - originY,
1440
1448
  );
1441
1449
  }
1442
1450
 
@@ -1470,13 +1478,19 @@ class RenderingContext2d {
1470
1478
  // what the bitmap glyph path draws at anyway, and they keep the key
1471
1479
  // stable as the paragraph moves — `(x + a) - (x + b)` is not exactly
1472
1480
  // `a - b` in floating point, and an origin-dependent key would miss the
1473
- // cache on every scroll.
1481
+ // cache on every scroll. Rounding alone does not absorb that where
1482
+ // `a - b` is on a half pixel: the difference came to a hair under it at
1483
+ // x 104 and exactly on it at x 152, and rounded two ways. So both
1484
+ // origins are snapped first, as `positionGlyphs` snaps them (issue
1485
+ // #350), and snapped origins subtract exactly.
1474
1486
  const ax = positioned[0].x;
1475
1487
  const ay = positioned[0].y;
1488
+ const sx = snapOrigin(ax);
1489
+ const sy = snapOrigin(ay);
1476
1490
  const local = positioned.map((p) => ({
1477
1491
  run: p.run,
1478
- x: Math.round(p.x - ax),
1479
- y: Math.round(p.y - ay),
1492
+ x: Math.round(snapOrigin(p.x) - sx),
1493
+ y: Math.round(snapOrigin(p.y) - sy),
1480
1494
  textRendering: p.textRendering,
1481
1495
  }));
1482
1496
  const key = `${local
@@ -1516,11 +1530,12 @@ class RenderingContext2d {
1516
1530
  return coverage;
1517
1531
  });
1518
1532
  if (surface) {
1533
+ // anchored as fillText's shadow is, so it moves with the text
1519
1534
  const origin = surface._shadowOrigin;
1520
1535
  this._paintShadow(
1521
1536
  surface,
1522
- Math.round(ax + this._shadowOffsetX) - origin.x,
1523
- Math.round(ay + this._shadowOffsetY) - origin.y,
1537
+ roundFrom(ax, this._shadowOffsetX) - origin.x,
1538
+ roundFrom(ay, this._shadowOffsetY) - origin.y,
1524
1539
  );
1525
1540
  return;
1526
1541
  }
@@ -319,6 +319,11 @@ export function encodeGlyphItems(items, bits) {
319
319
  return { gsid, bits, elts };
320
320
  }
321
321
 
322
+ // steps per pixel a text origin is snapped to before anything rounds against
323
+ // it (see positionGlyphs and snapOrigin); a power of two, so the snap and the
324
+ // split are exact
325
+ const ORIGIN_STEPS = 256;
326
+
322
327
  /**
323
328
  * Compute device positions for shaped runs — the single source of truth for
324
329
  * where each glyph's origin lands, shared by the renderer and by tests that
@@ -328,20 +333,86 @@ export function encodeGlyphItems(items, bits) {
328
333
  * Font.shape()/shapeText() (already in visual order) and x/y is the run's
329
334
  * baseline origin in device space.
330
335
  *
336
+ * Placement is translation-invariant (issue #350): the same run drawn whole
337
+ * pixels away puts every glyph exactly that many pixels away, so a renderer
338
+ * that copies pixels it already drew — a scroll blit — matches a repaint
339
+ * byte for byte. The origin arrives as a whole-pixel position plus the
340
+ * layout's fractional offsets, summed in floating point, and the last bits
341
+ * of that sum depend on the whole part's magnitude: rounded as one number, a
342
+ * pen exactly on a half pixel went down at x 104 and up at x 152. So the
343
+ * origin is snapped to 1/256 px first and split into whole pixels and a
344
+ * fraction in [0, 1), and the pen walks from the fraction alone. Two origins
345
+ * whole pixels apart snap to fractions that are bit-identical, so every
346
+ * rounding after the snap sees the same numbers wherever the run is drawn.
347
+ * (Taking the fraction after the snap is what keeps it below 1: an origin a
348
+ * hair under a whole pixel and one exactly on it walk the same pen from 0.)
349
+ *
350
+ * The snap moves a glyph only where its pen lies within 1/512 px of a
351
+ * rounding boundary. The one knife-edge left is the snap's own — an origin
352
+ * within an ulp of an odd multiple of 1/512 px — a far narrower target than
353
+ * any pen along the run landing on a half pixel.
354
+ *
331
355
  * @returns {Array<{run, glyph, x, y}>} integer glyph-origin positions
332
356
  */
333
357
  export function positionGlyphs(positioned) {
334
358
  const out = [];
335
359
  for (const { run, x, y } of positioned) {
336
- let cursor = x;
360
+ const sx = snapOrigin(x);
361
+ const sy = snapOrigin(y);
362
+ const ox = Math.floor(sx);
363
+ const oy = Math.floor(sy);
364
+ const fy = sy - oy;
365
+ let cursor = sx - ox;
337
366
  for (const g of run.glyphs) {
338
- out.push({ run, glyph: g, x: Math.round(cursor + g.dx), y: Math.round(y - g.dy) });
367
+ out.push({
368
+ run,
369
+ glyph: g,
370
+ x: ox + Math.round(cursor + g.dx),
371
+ y: oy + Math.round(fy - g.dy)
372
+ });
339
373
  cursor += g.ax;
340
374
  }
341
375
  }
342
376
  return out;
343
377
  }
344
378
 
379
+ /**
380
+ * A device-space origin snapped to 1/256 px, the way `positionGlyphs` takes
381
+ * a run's origin before anything rounds against it (issue #350).
382
+ *
383
+ * What comes back is an exact multiple of 1/256, so whole pixels split off
384
+ * it, and two snapped origins subtract, with no rounding at all. Origins
385
+ * whole pixels apart snap exactly that many pixels apart, bit for bit, even
386
+ * though the origins themselves — a whole-pixel position plus fractional
387
+ * layout offsets, summed in floating point — differ in their last bits.
388
+ * Anything else that rounds against a text origin needs the same: a text
389
+ * shadow's anchor, and the offsets between the runs its coverage holds.
390
+ *
391
+ * @param {number} v a device coordinate
392
+ * @returns {number} `v` to the nearest 1/256 px
393
+ */
394
+ export function snapOrigin(v) {
395
+ return Math.round(v * ORIGIN_STEPS) / ORIGIN_STEPS;
396
+ }
397
+
398
+ /**
399
+ * `Math.round(origin + offset)`, rounded the way `positionGlyphs` rounds a
400
+ * glyph `offset` px from its run's origin: the snapped origin's whole
401
+ * pixels, plus its fraction and the offset rounded together. The one sum
402
+ * that rounds never sees the whole part, so moving the origin by whole
403
+ * pixels moves the answer by exactly those pixels, whatever the offset is.
404
+ * It is where a text shadow composites, `offset` being the shadow's own.
405
+ *
406
+ * @param {number} origin a device coordinate, a run's origin
407
+ * @param {number} offset px from it, along the same axis
408
+ * @returns {number} the whole pixel `origin + offset` lands on
409
+ */
410
+ export function roundFrom(origin, offset) {
411
+ const s = snapOrigin(origin);
412
+ const whole = Math.floor(s);
413
+ return whole + Math.round(s - whole + offset);
414
+ }
415
+
345
416
  /**
346
417
  * Ink extents of positioned runs, in whatever coordinates their origins are
347
418
  * given in — the union of every glyph's bounding box, laid out exactly as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.8.0",
3
+ "version": "8.8.1",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "linebreak": "^1.1.0",
45
45
  "parse-color": "^1.0.0",
46
46
  "pngjs": "^7.0.0",
47
- "x11": "^4.0.1"
47
+ "x11": "^4.1.0"
48
48
  },
49
49
  "optionalDependencies": {
50
50
  "x11-dri": ">=0.5.0 <1"