ntk 8.19.0 → 8.20.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 +1 -1
- package/lib/image.js +125 -4
- package/lib/index.js +2 -1
- package/package.json +1 -1
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`
|
|
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/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
|
-
|
|
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,
|