ntk 8.19.0 → 8.21.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.
package/README.md CHANGED
@@ -136,7 +136,7 @@ parser, say — can import just that piece, and loads only what it needs:
136
136
  | `ntk/gl` | `GLError`, `GL_MODES`, `DEFAULT_GL_POLICY`, … | — |
137
137
  | `ntk/shadow-math` | `shadowSigma`, `shadowReach`, `blurScale`, … | — |
138
138
  | `ntk/shadow-tiles` | `planShadowTiles`, `shadowTileAlpha`, … | — |
139
- | `ntk/image` | `Image`, `decodeImage`, `loadImage` | `jpeg-js`, `pngjs` |
139
+ | `ntk/image` | `Image`, `decodeImage`, `loadImage`, `exifOrientation` | `jpeg-js`, `pngjs` |
140
140
  | `ntk/svg` | `SvgView` (default export) | `htmlparser2`, `domutils` |
141
141
  | `ntk/font` | `Font` (default export) | `fontkit` |
142
142
  | `ntk/xembed` | the XEmbed protocol ([docs/xembed.md](docs/xembed.md)) | `x11` |
package/lib/app.js CHANGED
@@ -25,6 +25,7 @@ import { DEFAULT_RASTER_POLICY, defaultRasterizer } from './rasterize.js';
25
25
  import { dropShadowSurfaces } from './shadow.js';
26
26
  import { sharedGlyphsFor } from './sharedglyphs.js';
27
27
  import { ShmUploader } from './shm-upload.js';
28
+ import Surface from './surface.js';
28
29
  import FontManager from './text/fontmanager.js';
29
30
  import Window from './window.js';
30
31
  import * as xevents from './events_map.js';
@@ -967,6 +968,16 @@ export default class App {
967
968
  return new Pixmap(this, args);
968
969
  }
969
970
 
971
+ /**
972
+ * An offscreen `Surface` on this connection — `new Surface(app, args)`,
973
+ * for code that has an app in hand and not the module: what `SvgView`
974
+ * draws a masked element on, without the SVG subpath loading the X11
975
+ * client.
976
+ */
977
+ createSurface(args) {
978
+ return new Surface(this, args);
979
+ }
980
+
970
981
  /**
971
982
  * The XFIXES extension for this connection, loaded once and shared.
972
983
  *
package/lib/image.js CHANGED
@@ -107,13 +107,28 @@ export class Image {
107
107
 
108
108
  const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47];
109
109
 
110
+ const ORIENTATIONS = ['from-image', 'none'];
111
+
110
112
  /**
111
113
  * Decode a PNG or JPEG buffer (sniffed by magic bytes) into an Image.
112
114
  *
115
+ * A JPEG comes out the way up its EXIF Orientation says, as a browser shows
116
+ * it (CSS `image-orientation: from-image`, the default since 2020) and as
117
+ * every phone photo expects: a camera held upright writes the sensor's rows
118
+ * as they were read, sideways, and records the turn in the tag rather than
119
+ * in the pixels. `{ imageOrientation: 'none' }` keeps the stored pixels, for
120
+ * an app that applies the turn itself — `exifOrientation()` says which.
121
+ *
113
122
  * @param {Buffer|Uint8Array} buffer encoded image bytes
123
+ * @param {{ imageOrientation?: 'from-image' | 'none' }} [options]
114
124
  * @returns {Image}
115
125
  */
116
- export function decodeImage(buffer) {
126
+ export function decodeImage(buffer, { imageOrientation = 'from-image' } = {}) {
127
+ if (!ORIENTATIONS.includes(imageOrientation)) {
128
+ throw new Error(
129
+ `decodeImage: imageOrientation must be 'from-image' or 'none', not ${JSON.stringify(imageOrientation)}`
130
+ );
131
+ }
117
132
  const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
118
133
  if (buf.length > 4 && PNG_MAGIC.every((b, i) => buf[i] === b)) {
119
134
  const png = PNG.sync.read(buf);
@@ -121,6 +136,8 @@ export function decodeImage(buffer) {
121
136
  }
122
137
  if (buf.length > 2 && buf[0] === 0xff && buf[1] === 0xd8) {
123
138
  const img = jpeg.decode(buf, { useTArray: true, maxMemoryUsageInMB: 512 });
139
+ const orientation = imageOrientation === 'none' ? 1 : exifOrientation(buf);
140
+ if (orientation !== 1) return new Image(orientPixels(img, orientation));
124
141
  return new Image({ width: img.width, height: img.height, data: Buffer.from(img.data) });
125
142
  }
126
143
  throw new Error('decodeImage: unsupported image format (PNG and JPEG are supported)');
@@ -134,16 +151,120 @@ export function decodeImage(buffer) {
134
151
  *
135
152
  * @param {string|URL|Buffer|Uint8Array} source file path (or file URL), or
136
153
  * an in-memory buffer of encoded bytes
154
+ * @param {{ imageOrientation?: 'from-image' | 'none' }} [options] as for
155
+ * `decodeImage`
137
156
  * @returns {Promise<Image>}
138
157
  */
139
- export async function loadImage(source) {
158
+ export async function loadImage(source, options) {
140
159
  if (typeof source === 'string' || source instanceof URL) {
141
160
  // lazy builtin lookup: browser bundles must not depend on node:fs
142
161
  const fsp = builtin('node:fs/promises');
143
162
  if (!fsp) {
144
163
  throw new Error('loadImage: file paths need node — pass the encoded bytes instead');
145
164
  }
146
- return decodeImage(await fsp.readFile(source));
165
+ return decodeImage(await fsp.readFile(source), options);
166
+ }
167
+ return decodeImage(source, options);
168
+ }
169
+
170
+ const APP1 = 0xe1;
171
+ const SOS = 0xda;
172
+ const EOI = 0xd9;
173
+ const ORIENTATION_TAG = 0x0112;
174
+ const SHORT = 3;
175
+
176
+ /**
177
+ * The EXIF Orientation of a JPEG, 1 to 8 — the TIFF numbering, where 1 is
178
+ * as stored, 2 to 4 mirror left to right, turn half way and mirror top to
179
+ * bottom, 6 and 8 turn a quarter clockwise and anticlockwise, and 5 and 7
180
+ * are those quarter turns mirrored. 1 for anything else: no Exif, no tag, a
181
+ * value out of range, a segment cut short, or bytes that are not a JPEG.
182
+ *
183
+ * Only the tag itself is read: the first APP1 segment that carries Exif,
184
+ * tag 0x0112 of its IFD0, and only as the one SHORT the standard defines it
185
+ * as. The segments before the scan are walked by their lengths, so the cost
186
+ * is a few reads whatever the size of the image.
187
+ *
188
+ * @param {Buffer|Uint8Array} buf encoded image bytes
189
+ * @returns {number}
190
+ */
191
+ export function exifOrientation(buf) {
192
+ if (!(buf.length > 2 && buf[0] === 0xff && buf[1] === 0xd8)) return 1;
193
+ for (let at = 2; at + 4 <= buf.length; ) {
194
+ if (buf[at] !== 0xff) return 1;
195
+ const marker = buf[at + 1];
196
+ // a marker may follow any number of 0xff fill bytes
197
+ if (marker === 0xff) {
198
+ at++;
199
+ continue;
200
+ }
201
+ // the metadata segments all come before the scan
202
+ if (marker === SOS || marker === EOI) return 1;
203
+ const end = at + 2 + ((buf[at + 2] << 8) | buf[at + 3]);
204
+ if (end < at + 4 || end > buf.length) return 1;
205
+ // the payload opens with "Exif\0\0" and the TIFF header after it, 14 bytes
206
+ if (marker === APP1 && end - (at + 4) >= 14 && isExifHeader(buf, at + 4)) {
207
+ return ifd0Orientation(buf, at + 10, end);
208
+ }
209
+ at = end;
210
+ }
211
+ return 1;
212
+ }
213
+
214
+ /** "Exif\0\0", which tells Exif's APP1 from XMP's */
215
+ function isExifHeader(buf, at) {
216
+ return (
217
+ buf[at] === 0x45 && buf[at + 1] === 0x78 && buf[at + 2] === 0x69 && buf[at + 3] === 0x66 &&
218
+ buf[at + 4] === 0 && buf[at + 5] === 0
219
+ );
220
+ }
221
+
222
+ /** The Orientation in the IFD0 of the TIFF structure from `tiff` to `end`. */
223
+ function ifd0Orientation(buf, tiff, end) {
224
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
225
+ // 'II' is little-endian, 'MM' big; both are written, by cameras and by editors
226
+ const little = buf[tiff] === 0x49 && buf[tiff + 1] === 0x49;
227
+ if (!little && !(buf[tiff] === 0x4d && buf[tiff + 1] === 0x4d)) return 1;
228
+ if (view.getUint16(tiff + 2, little) !== 42) return 1;
229
+ const ifd = tiff + view.getUint32(tiff + 4, little);
230
+ if (ifd + 2 > end) return 1;
231
+ const count = view.getUint16(ifd, little);
232
+ for (let i = 0, entry = ifd + 2; i < count && entry + 12 <= end; i++, entry += 12) {
233
+ if (view.getUint16(entry, little) !== ORIENTATION_TAG) continue;
234
+ if (view.getUint16(entry + 2, little) !== SHORT || view.getUint32(entry + 4, little) !== 1) return 1;
235
+ // one SHORT sits in the first two bytes of the entry's value field
236
+ const value = view.getUint16(entry + 8, little);
237
+ return value >= 1 && value <= 8 ? value : 1;
238
+ }
239
+ return 1;
240
+ }
241
+
242
+ /**
243
+ * Stored RGBA pixels turned the way `orientation` says, into a new buffer:
244
+ * where stored pixel (x, y) lands is `start + x * dx + y * dy`, in pixels of
245
+ * the result, which is `height` wide for the four that turn a quarter. Each
246
+ * pixel moves as one 32-bit word, so the byte order is never looked at.
247
+ */
248
+ function orientPixels({ width: w, height: h, data }, orientation) {
249
+ const n = w * h;
250
+ const [start, dx, dy] = [
251
+ null,
252
+ [0, 1, w], // as stored
253
+ [w - 1, -1, w], // mirrored left to right
254
+ [n - 1, -1, -w], // half turn
255
+ [n - w, 1, -w], // mirrored top to bottom
256
+ [0, h, 1], // transposed: mirrored, then a quarter turn anticlockwise
257
+ [h - 1, h, -1], // a quarter turn clockwise
258
+ [n - 1, -h, -1], // transversed: mirrored, then a quarter turn clockwise
259
+ [n - h, -h, 1] // a quarter turn anticlockwise
260
+ ][orientation];
261
+ // jpeg-js hands over a fresh array, so its offset is word-aligned
262
+ const from = new Uint32Array(data.buffer, data.byteOffset, n);
263
+ const out = Buffer.alloc(n * 4);
264
+ const to = new Uint32Array(out.buffer, out.byteOffset, n);
265
+ for (let y = 0, i = 0; y < h; y++) {
266
+ for (let x = 0, j = start + y * dy; x < w; x++, j += dx) to[j] = from[i++];
147
267
  }
148
- return decodeImage(source);
268
+ const turned = orientation >= 5;
269
+ return { width: turned ? h : w, height: turned ? w : h, data: out };
149
270
  }
package/lib/index.js CHANGED
@@ -20,7 +20,7 @@ import { decodeKey, groupForState } from './keyboard.js';
20
20
  import Pixmap from './pixmap.js';
21
21
  import Picture from './picture.js';
22
22
  import Region from './region.js';
23
- import { Image, decodeImage, loadImage } from './image.js';
23
+ import { Image, decodeImage, exifOrientation, loadImage } from './image.js';
24
24
  import {
25
25
  ImageData,
26
26
  fromStraightRgba,
@@ -274,6 +274,7 @@ export {
274
274
  unpackIcons,
275
275
  decodeImage,
276
276
  loadImage,
277
+ exifOrientation,
277
278
  Path2D,
278
279
  parseSvgPath,
279
280
  Font,
package/lib/path.js CHANGED
@@ -454,7 +454,10 @@ export function parseSvgPath(d) {
454
454
  }
455
455
  const nx = rel ? x + e : e;
456
456
  const ny = rel ? y + f : f;
457
- for (const c of svgArcCommands(x, y, rx, ry, rot, large, sweep, nx, ny)) cmds.push(c);
457
+ // one segment, for a marker: the curves it is cut into but the last
458
+ // end at no vertex of the path's
459
+ const arc = svgArcCommands(x, y, rx, ry, rot, large, sweep, nx, ny);
460
+ arc.forEach((c, k) => cmds.push(k < arc.length - 1 ? { ...c, arcPart: true } : c));
458
461
  x = nx;
459
462
  y = ny;
460
463
  break;