ntk 2.1.1 → 3.0.0

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.
Files changed (48) hide show
  1. package/README.md +115 -110
  2. package/lib/app.js +48 -15
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +10 -16
  5. package/lib/events_map.js +81 -87
  6. package/lib/fontconfig.js +122 -14
  7. package/lib/glyphset.js +36 -18
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +110 -52
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +76 -38
  12. package/lib/pixmap.js +34 -25
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +994 -517
  15. package/lib/renderingcontext_opengl.js +38 -31
  16. package/lib/renderingcontext_x11.js +56 -64
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +568 -246
  33. package/package.json +35 -32
  34. package/.npmignore +0 -15
  35. package/examples/example-await.js +0 -23
  36. package/examples/example-co.js +0 -13
  37. package/examples/example.js +0 -14
  38. package/examples/fill-styles.js +0 -39
  39. package/examples/glclock.js +0 -100
  40. package/examples/glxpixmap.js +0 -12
  41. package/examples/gradient.js +0 -41
  42. package/examples/package.json +0 -17
  43. package/examples/simpletex.js +0 -72
  44. package/examples/teapot.js +0 -104
  45. package/examples/teapot.json +0 -6
  46. package/examples/text-rendering.js +0 -44
  47. package/lib/fontface.js +0 -109
  48. package/lib/widgets/webview.js +0 -33
package/lib/fontconfig.js CHANGED
@@ -1,15 +1,123 @@
1
- module.exports.listFontsSync = function(pattern) {
2
- var fmPattern = {};
3
- fmPattern.family = pattern.family;
4
- if (pattern.style.indexOf('italic') != -1)
5
- fmPattern.italic = true;
6
- if (pattern.weight == 'normal')
7
- fmPattern.weight = 400;
8
- else if (pattern.weight == 'bold')
9
- fmPattern.weight = 700;
10
- else if (!isNaN(parseInt(pattern.weight)))
11
- fmPattern.weight = parseInt(pattern.weight);
12
-
13
- var match = require('font-manager').findFontSync(fmPattern);
14
- return match;
1
+ // node:child_process is fetched lazily (not via static import) so that
2
+ // browser bundles of the package never try to resolve it; in non-node
3
+ // environments use a custom FontSource instead (see text/fontsource.js).
4
+ function execFileSync(...args) {
5
+ const cp = globalThis.process?.getBuiltinModule?.('node:child_process');
6
+ if (!cp) {
7
+ throw new Error(
8
+ 'fontconfig matching needs node (fc-match CLI); in this environment pass a custom fontSource — see docs/fonts.md'
9
+ );
10
+ }
11
+ return cp.execFileSync(...args);
12
+ }
13
+
14
+ // css weight -> fontconfig weight constants
15
+ const cssToFcWeight = {
16
+ 100: 0, // thin
17
+ 200: 40, // extralight
18
+ 300: 50, // light
19
+ 400: 80, // regular
20
+ 500: 100, // medium
21
+ 600: 180, // demibold
22
+ 700: 200, // bold
23
+ 800: 205, // extrabold
24
+ 900: 210 // black
15
25
  };
26
+
27
+ // formats fontkit can parse
28
+ const supported = /\.(ttf|otf|woff|woff2|ttc|dfont)$/i;
29
+
30
+ const sortedCache = new Map();
31
+
32
+ function patternFor({ family, weight, style }) {
33
+ let fc = family || 'sans-serif';
34
+ const fcWeight = normalizeWeight(weight);
35
+ if (fcWeight !== undefined) fc += `:weight=${fcWeight}`;
36
+ if (style && style.includes('italic')) fc += ':slant=italic';
37
+ return fc;
38
+ }
39
+
40
+ /**
41
+ * Full fontconfig match list for a pattern, best match first — this is the
42
+ * system's font fallback chain. Each candidate carries the unicode coverage
43
+ * fontconfig knows about (`charset`, lazily parsed via `charsetHas`), so a
44
+ * fallback font for a codepoint can be chosen without opening font files.
45
+ *
46
+ * Cached per pattern; one fc-match invocation (~50ms) per distinct pattern.
47
+ *
48
+ * @returns {Array<{path, postscriptName, charset: string}>}
49
+ */
50
+ export function matchSortedSync(pattern) {
51
+ const fc = patternFor(pattern);
52
+ let list = sortedCache.get(fc);
53
+ if (list) return list;
54
+
55
+ const out = execFileSync(
56
+ 'fc-match',
57
+ ['-s', '--format', '%{file}\t%{postscriptname}\t%{charset}\n', fc],
58
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
59
+ );
60
+ list = [];
61
+ for (const line of out.split('\n')) {
62
+ const [path, postscriptName, charset] = line.split('\t');
63
+ if (path && supported.test(path)) {
64
+ list.push({ path, postscriptName, charset: charset || '', _ranges: null });
65
+ }
66
+ }
67
+ if (list.length === 0) {
68
+ throw new Error(`No usable font found for pattern "${fc}" (need .ttf/.otf/.ttc)`);
69
+ }
70
+ sortedCache.set(fc, list);
71
+ return list;
72
+ }
73
+
74
+ /**
75
+ * Resolve a font pattern ({family, weight, style}) to the best matching font
76
+ * file. Returns { path, postscriptName } or throws if nothing suitable is
77
+ * installed. Requires the fc-match CLI (fontconfig), present on any system
78
+ * running X11.
79
+ */
80
+ export function listFontsSync(pattern) {
81
+ const [best] = matchSortedSync(pattern);
82
+ return { path: best.path, postscriptName: best.postscriptName };
83
+ }
84
+
85
+ /**
86
+ * Does a fc-match candidate's charset cover a codepoint?
87
+ * The charset string is fontconfig's range format: "20-7e a0-ff 131 ...".
88
+ */
89
+ export function charsetHas(candidate, codepoint) {
90
+ if (candidate._ranges === null) {
91
+ const ranges = [];
92
+ for (const part of candidate.charset.split(' ')) {
93
+ if (!part) continue;
94
+ const dash = part.indexOf('-');
95
+ if (dash === -1) {
96
+ const v = parseInt(part, 16);
97
+ ranges.push(v, v);
98
+ } else {
99
+ ranges.push(parseInt(part.slice(0, dash), 16), parseInt(part.slice(dash + 1), 16));
100
+ }
101
+ }
102
+ candidate._ranges = ranges;
103
+ }
104
+ const r = candidate._ranges;
105
+ let lo = 0;
106
+ let hi = r.length / 2 - 1;
107
+ while (lo <= hi) {
108
+ const mid = (lo + hi) >> 1;
109
+ if (codepoint < r[mid * 2]) hi = mid - 1;
110
+ else if (codepoint > r[mid * 2 + 1]) lo = mid + 1;
111
+ else return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function normalizeWeight(weight) {
117
+ if (weight === undefined) return undefined;
118
+ if (weight === 'normal') return cssToFcWeight[400];
119
+ if (weight === 'bold') return cssToFcWeight[700];
120
+ const n = parseInt(weight, 10);
121
+ if (Number.isNaN(n)) return undefined;
122
+ return cssToFcWeight[n] !== undefined ? cssToFcWeight[n] : n;
123
+ }
package/lib/glyphset.js CHANGED
@@ -1,22 +1,40 @@
1
- var weak = require('weak');
1
+ import { safeRelease } from './cleanup.js';
2
2
 
3
- function GlyphSet(app, args)
4
- {
5
- var X = app.X;
6
- this.X = X;
7
- var display = app.display;
8
- this.Render = display.Render;
9
- this.display = display;
10
- this.id = X.AllocID();
11
- this.Render.CreateGlyphSet(this.id, this.Render.a8);
12
- var ref = weak(this, function (obj) {
13
- X.FreeGlyphSet(obj.id);
3
+ const registry = new FinalizationRegistry(({ Render, X, id }) => {
4
+ safeRelease(X, () => {
5
+ Render.FreeGlyphSet(id);
6
+ X.ReleaseID(id);
14
7
  });
15
- }
8
+ });
9
+
10
+ export default class GlyphSet {
11
+ constructor(app, format) {
12
+ const X = app.X;
13
+ this.X = X;
14
+ this.display = app.display;
15
+ this.Render = app.display.Render;
16
+ this.id = X.AllocID();
17
+ this.Render.CreateGlyphSet(this.id, format || this.Render.a8);
18
+ this._owned = true;
19
+ registry.register(this, { Render: this.Render, X, id: this.id }, this);
20
+ }
16
21
 
17
- GlyphSet.prototype.addGlyphs = function(glyphs) {
18
- this.Render.AddGlyphs(this.id, glyphs);
19
- this._glyphs = glyphs;
20
- };
22
+ addGlyphs(glyphs) {
23
+ if (glyphs.length === 0) return;
24
+ this.Render.AddGlyphs(this.id, glyphs);
25
+ }
21
26
 
22
- module.exports = GlyphSet;
27
+ destroy() {
28
+ if (!this._owned) return;
29
+ this._owned = false;
30
+ registry.unregister(this);
31
+ safeRelease(this.X, () => {
32
+ this.Render.FreeGlyphSet(this.id);
33
+ this.X.ReleaseID(this.id);
34
+ });
35
+ }
36
+
37
+ [Symbol.dispose]() {
38
+ this.destroy();
39
+ }
40
+ }
package/lib/image.js ADDED
@@ -0,0 +1,138 @@
1
+ import jpeg from 'jpeg-js';
2
+ import { PNG } from 'pngjs';
3
+
4
+ import Picture from './picture.js';
5
+
6
+ /**
7
+ * A decoded raster image: `width`, `height` and non-premultiplied RGBA
8
+ * pixels in `data`. Images are client-side objects independent of any X
9
+ * connection; the first time one is drawn it is uploaded to that server
10
+ * as a 32-bit pixmap and cached, so repeated draws are a single
11
+ * server-side composite.
12
+ *
13
+ * Create with `loadImage(pathOrBuffer)` / `decodeImage(buffer)`, or from
14
+ * raw pixels: `new Image({ width, height, data })`.
15
+ */
16
+ export class Image {
17
+ constructor({ width, height, data }) {
18
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
19
+ throw new Error('Image: width and height must be positive integers');
20
+ }
21
+ if (!data || data.length !== width * height * 4) {
22
+ throw new Error(`Image: data must be RGBA, ${width * height * 4} bytes`);
23
+ }
24
+ this.width = width;
25
+ this.height = height;
26
+ this.data = data;
27
+ this._uploads = new Map(); // App -> { pixmap, picture }
28
+ }
29
+
30
+ /**
31
+ * The server-side Picture for this image on `app`'s display (uploaded
32
+ * and cached on first use). Used by `ctx.drawImage`; useful directly
33
+ * when compositing manually via the Render extension.
34
+ */
35
+ picture(app) {
36
+ let upload = this._uploads.get(app);
37
+ if (upload) return upload.picture;
38
+
39
+ const X = app.X;
40
+ const Render = app.display.Render;
41
+ const pixmap = app.createPixmap({ depth: 32, width: this.width, height: this.height });
42
+
43
+ // XRender composites premultiplied alpha; PutImage on a 32-bit ZPixmap
44
+ // wants BGRA byte order (see AGENTS.md gotchas)
45
+ const bgra = Buffer.allocUnsafe(this.width * this.height * 4);
46
+ const src = this.data;
47
+ for (let i = 0; i < bgra.length; i += 4) {
48
+ const a = src[i + 3];
49
+ if (a === 255) {
50
+ bgra[i] = src[i + 2];
51
+ bgra[i + 1] = src[i + 1];
52
+ bgra[i + 2] = src[i];
53
+ } else {
54
+ bgra[i] = (src[i + 2] * a + 127) / 255;
55
+ bgra[i + 1] = (src[i + 1] * a + 127) / 255;
56
+ bgra[i + 2] = (src[i] * a + 127) / 255;
57
+ }
58
+ bgra[i + 3] = a;
59
+ }
60
+
61
+ // node-x11 has no FreeGC — share one upload GC per app (valid for any
62
+ // depth-32 drawable on the screen)
63
+ let gc = app._imageUploadGC;
64
+ if (!gc) {
65
+ gc = app._imageUploadGC = X.AllocID();
66
+ X.CreateGC(gc, pixmap.id);
67
+ }
68
+ // stay under the server's maximum request size by uploading row bands
69
+ const stride = this.width * 4;
70
+ const maxBytes = ((app.display.max_request_length ?? 65535) - 8) * 4;
71
+ const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
72
+ for (let y = 0; y < this.height; y += rowsPerBand) {
73
+ const rows = Math.min(rowsPerBand, this.height - y);
74
+ X.PutImage(2, pixmap.id, gc, this.width, rows, 0, y, 0, 32, bgra.subarray(y * stride, (y + rows) * stride));
75
+ }
76
+
77
+ const picture = new Picture(app, { drawable: pixmap, format: Render.rgba32 });
78
+ upload = { pixmap, picture };
79
+ this._uploads.set(app, upload);
80
+ return picture;
81
+ }
82
+
83
+ /** free server-side copies of this image (safe to draw again afterwards) */
84
+ destroy() {
85
+ for (const { pixmap, picture } of this._uploads.values()) {
86
+ picture.destroy();
87
+ pixmap.destroy();
88
+ }
89
+ this._uploads.clear();
90
+ }
91
+
92
+ [Symbol.dispose]() {
93
+ this.destroy();
94
+ }
95
+ }
96
+
97
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47];
98
+
99
+ /**
100
+ * Decode a PNG or JPEG buffer (sniffed by magic bytes) into an Image.
101
+ *
102
+ * @param {Buffer|Uint8Array} buffer encoded image bytes
103
+ * @returns {Image}
104
+ */
105
+ export function decodeImage(buffer) {
106
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
107
+ if (buf.length > 4 && PNG_MAGIC.every((b, i) => buf[i] === b)) {
108
+ const png = PNG.sync.read(buf);
109
+ return new Image({ width: png.width, height: png.height, data: png.data });
110
+ }
111
+ if (buf.length > 2 && buf[0] === 0xff && buf[1] === 0xd8) {
112
+ const img = jpeg.decode(buf, { useTArray: true, maxMemoryUsageInMB: 512 });
113
+ return new Image({ width: img.width, height: img.height, data: Buffer.from(img.data) });
114
+ }
115
+ throw new Error('decodeImage: unsupported image format (PNG and JPEG are supported)');
116
+ }
117
+
118
+ /**
119
+ * Load and decode a PNG or JPEG image.
120
+ *
121
+ * const img = await loadImage('logo.png');
122
+ * ctx.drawImage(img, 10, 10);
123
+ *
124
+ * @param {string|URL|Buffer|Uint8Array} source file path (or file URL), or
125
+ * an in-memory buffer of encoded bytes
126
+ * @returns {Promise<Image>}
127
+ */
128
+ export async function loadImage(source) {
129
+ if (typeof source === 'string' || source instanceof URL) {
130
+ // lazy builtin lookup: browser bundles must not depend on node:fs
131
+ const fsp = globalThis.process?.getBuiltinModule?.('node:fs/promises');
132
+ if (!fsp) {
133
+ throw new Error('loadImage: file paths need node — pass the encoded bytes instead');
134
+ }
135
+ return decodeImage(await fsp.readFile(source));
136
+ }
137
+ return decodeImage(source);
138
+ }
package/lib/index.js CHANGED
@@ -1,65 +1,123 @@
1
- var Promise = require('es6-promise').Promise;
2
- var x11 = require('x11');
3
- var App = require('./app');
1
+ import x11 from 'x11';
4
2
 
3
+ import App from './app.js';
4
+ import Window from './window.js';
5
+ import Pixmap from './pixmap.js';
6
+ import Picture from './picture.js';
7
+ import { Image, decodeImage, loadImage } from './image.js';
8
+ import { Path2D, parseSvgPath } from './path.js';
9
+ import Font from './text/font.js';
10
+ import FontManager from './text/fontmanager.js';
11
+ import {
12
+ FontconfigFontSource,
13
+ StaticFontSource,
14
+ defaultFontSource,
15
+ setDefaultFontSource
16
+ } from './text/fontsource.js';
17
+ import { TextLayout } from './text/layout.js';
18
+ import HtmlView from './widgets/htmlview.js';
19
+ import SvgView from './widgets/svgview.js';
20
+ import MarkdownView from './widgets/markdownview.js';
21
+ import TexView, { configureTex, layoutTex, TexBox } from './widgets/tex.js';
22
+ import { tokenize as highlightCode } from './widgets/highlight.js';
5
23
 
6
- module.exports.createClient = function(main) {
7
- return new Promise(function(resolve, reject) {
24
+ // rendering context modules register themselves on Drawable
25
+ import './renderingcontext_x11.js';
26
+ import './renderingcontext_2d.js';
27
+ import './renderingcontext_opengl.js';
8
28
 
9
- var callback = function(err, display) {
10
- if (err) {
11
- reject(err);
12
- if (main)
13
- return main(err);
14
- }
15
- resolve(display);
16
- if (main)
17
- return main(null, display);
18
- }
29
+ /**
30
+ * Connect to the X server and resolve with an App.
31
+ *
32
+ * const app = await createClient();
33
+ *
34
+ * The legacy node-style callback is still supported:
35
+ *
36
+ * createClient((err, app) => { ... });
37
+ *
38
+ * @param {object} [options] passed through to x11.createClient
39
+ * (e.g. { display: ':1' }); ntk additionally understands
40
+ * { fontSource } — a pluggable system-font lookup (see docs/fonts.md) —
41
+ * and { glxVisual } — the visual id for getContext('opengl') in
42
+ * environments where `glxinfo` cannot be shelled out to
43
+ * @param {function} [callback] optional node-style callback
44
+ * @returns {Promise<App>}
45
+ */
46
+ export function createClient(options, callback) {
47
+ if (typeof options === 'function') {
48
+ callback = options;
49
+ options = {};
50
+ }
19
51
 
20
- x11.createClient({ debug: true }, function(error, display) {
21
- if (error)
22
- return callback(error, null);
52
+ const promise = new Promise((resolve, reject) => {
53
+ x11.createClient(options || {}, (error, display) => {
54
+ if (error) return reject(error);
23
55
 
24
- display.client.on('error', function(err) {
25
- console.log(err.longstack);
26
- process.exit(1);
27
- });
28
-
29
- display.client.require('glx', function(err, GLX) {
30
- display.client.require('render', function(err, Render) {
31
- // we prelod Render and GLX
32
- display.Render = Render;
33
- display.GLX = GLX;
34
- var X = display.client;
35
- X.keycode2keysyms = {}
36
-
37
- function updateKeyboardMapping(min, max) {
38
- if (max <= min)
39
- return;
40
-
41
- X.GetKeyboardMapping(min, max-min, function(err, list) {
42
- for (var i=0; i < list.length; ++i)
43
- X.keycode2keysyms[i+min] = list[i].slice();
44
- });
45
- }
56
+ display.client.require('glx', (glxError, GLX) => {
57
+ display.client.require('render', (renderError, Render) => {
58
+ if (renderError) return reject(renderError);
59
+ // preload Render and GLX; GLX is optional ( indirect GLX is often
60
+ // disabled on modern servers )
61
+ display.Render = Render;
62
+ display.GLX = glxError ? null : GLX;
63
+ const X = display.client;
64
+ X.keycode2keysyms = {};
46
65
 
47
- X.on('event', function(ev) {
48
- // MappingNotify
49
- if (ev.type == 34) {
50
- updateKeyboardMapping(ev.firstKeyCode, ev.firstKeyCode + ev.count);
51
- }
66
+ function updateKeyboardMapping(min, max) {
67
+ if (max <= min) return;
68
+ X.GetKeyboardMapping(min, max - min, (err, list) => {
69
+ if (err) return;
70
+ for (let i = 0; i < list.length; ++i) X.keycode2keysyms[i + min] = list[i].slice();
52
71
  });
53
- updateKeyboardMapping(display.min_keycode, display.max_keycode);
72
+ }
54
73
 
55
- var app = new App(display);
56
- callback(null, app);
74
+ X.on('event', (ev) => {
75
+ // MappingNotify
76
+ if (ev.type === 34) {
77
+ updateKeyboardMapping(ev.firstKeyCode, ev.firstKeyCode + ev.count);
78
+ }
57
79
  });
80
+ updateKeyboardMapping(display.min_keycode, display.max_keycode);
81
+
82
+ resolve(new App(display, options || {}));
58
83
  });
84
+ });
59
85
  });
60
86
  });
61
- };
62
87
 
63
- require('./renderingcontext_x11');
64
- require('./renderingcontext_2d');
65
- require('./renderingcontext_opengl');
88
+ if (callback) {
89
+ promise.then(
90
+ (app) => callback(null, app),
91
+ (err) => callback(err)
92
+ );
93
+ }
94
+ return promise;
95
+ }
96
+
97
+ export {
98
+ App,
99
+ Window,
100
+ Pixmap,
101
+ Picture,
102
+ Image,
103
+ decodeImage,
104
+ loadImage,
105
+ Path2D,
106
+ parseSvgPath,
107
+ Font,
108
+ FontManager,
109
+ FontconfigFontSource,
110
+ StaticFontSource,
111
+ defaultFontSource,
112
+ setDefaultFontSource,
113
+ TextLayout,
114
+ HtmlView,
115
+ SvgView,
116
+ MarkdownView,
117
+ TexView,
118
+ TexBox,
119
+ layoutTex,
120
+ configureTex,
121
+ highlightCode
122
+ };
123
+ export default { createClient };