ntk 8.18.1 → 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 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
@@ -52,6 +52,39 @@ const DEFAULT_FRAME_INTERVAL = 16;
52
52
  const MIN_REFRESH_RATE = 20;
53
53
  const MAX_REFRESH_RATE = 1000;
54
54
 
55
+ /**
56
+ * How many of the solids drawings make for themselves a connection keeps
57
+ * (`App#transientSolid`). A fade folds each new alpha into every colour it
58
+ * draws, so it makes a few a frame; a frame faded at one alpha draws with
59
+ * fewer than this, and finds them all again on the next.
60
+ */
61
+ const TRANSIENT_SOLIDS = 256;
62
+
63
+ /**
64
+ * How many of the solids contexts paint their colour styles with a
65
+ * connection keeps (`App#styleSolid`): `fillStyle`, `strokeStyle`, a
66
+ * `TextLayout` span's colour. An app's palette is far fewer; a colour
67
+ * animated through a transition or a tween is a new one every frame.
68
+ */
69
+ const STYLE_SOLIDS = 1024;
70
+
71
+ /** How many CSS colour strings it finds them by before starting over. */
72
+ const STYLE_NAMES = 1024;
73
+
74
+ // a solid, and on a server too old for CreateSolidFill the 1x1 pixmap it
75
+ // repeats
76
+ function freeSolid(picture) {
77
+ picture.destroy();
78
+ picture._sourcePixmap?.destroy();
79
+ }
80
+
81
+ // the value `key` names, taken out of `map`
82
+ function take(map, key) {
83
+ const value = map.get(key);
84
+ if (value !== undefined) map.delete(key);
85
+ return value;
86
+ }
87
+
55
88
  /**
56
89
  * A RandR mode's vertical refresh rate in Hz, or 0 when it does not describe
57
90
  * one.
@@ -147,9 +180,21 @@ export default class App {
147
180
  // client costs no I/O — discovery waits for the first page to bind.
148
181
  sharedGlyphsFor(this);
149
182
  this._cursors = null;
183
+ // the solids a caller holds for good (solidPicture)
150
184
  this._solidPictures = new Map();
151
- // the same pictures by the CSS colour that asked for them (solidPictureOf)
185
+ // the solids contexts paint their colour styles with, least recently
186
+ // used first; a clock that ticks as they are made and moved up; and the
187
+ // same pictures by the CSS colour that asked for them (styleSolid)
188
+ this._styleSolids = new Map();
189
+ this._styleSolidLimit = STYLE_SOLIDS;
190
+ this._styleClock = 0;
152
191
  this._solidByName = new Map();
192
+ // style solids let go of in this job, freed once it has run
193
+ this._evictedSolids = [];
194
+ // the solids one drawing makes for itself, least recently used first
195
+ // (transientSolid)
196
+ this._transientSolids = new Map();
197
+ this._transientSolidLimit = TRANSIENT_SOLIDS;
153
198
  this._rasterizer = undefined;
154
199
  this._shm = undefined;
155
200
  this._extensionPromises = new Map();
@@ -959,14 +1004,18 @@ export default class App {
959
1004
  }
960
1005
 
961
1006
  /**
962
- * A repeating source Picture of one colour, for compositing. Components
963
- * are 0..1 floats, premultiplied by alpha.
1007
+ * A repeating source Picture of one colour, for compositing, held for as
1008
+ * long as the connection is open. Components are 0..1 floats,
1009
+ * premultiplied by alpha.
964
1010
  *
965
- * Cached per connection, not per context: colours a context uses are the
966
- * app's palette, and `Surface.render` builds a context per call — a
967
- * per-context cache made every render recreate its fill colours
968
- * server-side. Nothing evicts; a solid is a few dozen bytes on the server
969
- * and an app cycles through few colours. Freed in `close()`.
1011
+ * Cached per connection, not per context: `Surface.render` builds a
1012
+ * context per call, and a per-context cache made every render recreate
1013
+ * its colours server-side. Nothing evicts: whoever asked may hold the
1014
+ * picture as long as it likes — a caller what `createSolidPicture` handed
1015
+ * back, a context its glyph source — so this is for the few colours an
1016
+ * app holds. Freed in `close()`. A context's colour styles are
1017
+ * `styleSolid`'s, and the colours a drawing makes for itself
1018
+ * `transientSolid`'s: once a colour animates, neither is few.
970
1019
  *
971
1020
  * On RENDER >= 0.10 a solid is one CreateSolidFill request; an older
972
1021
  * server (or a hand-built App that skipped the version handshake in
@@ -976,7 +1025,151 @@ export default class App {
976
1025
  const key = `${r}|${g}|${b}|${a}`;
977
1026
  let p = this._solidPictures.get(key);
978
1027
  if (p) return p;
1028
+ // one a style paints with, or a drawing made for itself, is held from
1029
+ // here on: out of its LRU, rather than made a second time
1030
+ p =
1031
+ take(this._styleSolids, key) ??
1032
+ take(this._transientSolids, key) ??
1033
+ this._createSolid(key, r, g, b, a);
1034
+ // and no LRU's clock ages it now (_usedStyle)
1035
+ p._used = Infinity;
1036
+ this._solidPictures.set(key, p);
1037
+ return p;
1038
+ }
979
1039
 
1040
+ /**
1041
+ * The solid a context paints a colour style with — `fillStyle`,
1042
+ * `strokeStyle`, a `TextLayout` span's colour: a CSS colour string, or a
1043
+ * premultiplied `[r, g, b, a]`. A string that is not a colour throws, as
1044
+ * `fillStyle` always has.
1045
+ *
1046
+ * Found by the string, where there is one: a UI sets the same few strings
1047
+ * again on every paint — every box's background, every border — and the
1048
+ * way to a solid is a parse, a premultiply and a key made of four numbers
1049
+ * turned into text, where by the string it is one lookup.
1050
+ *
1051
+ * Not held for good, as `solidPicture`'s are: a colour animated through a
1052
+ * transition or a tween is a new solid every frame, so the `STYLE_SOLIDS`
1053
+ * most recently used are kept and the rest let go. One let go is marked
1054
+ * `_evicted` at once, so that nothing hands it out again, and freed when
1055
+ * the job that let it go has run. X runs a client's requests in order, so
1056
+ * every request sent with it in that job reaches the server first: within
1057
+ * a job, holding one is safe however many colours are asked for meanwhile
1058
+ * — and a drawing does ask, taking its style before the shadow it paints
1059
+ * first makes colours of its own. Past the job, whoever holds one keeps
1060
+ * the style too, and asks again on finding the mark, as a context does
1061
+ * for `fillStyle` and `strokeStyle` (its `_backgroundPicture`).
1062
+ */
1063
+ styleSolid(value) {
1064
+ if (typeof value !== 'string') return this._styleSolid(value);
1065
+ const p = this._solidByName.get(value);
1066
+ if (p !== undefined && !p._evicted) return this._usedStyle(p);
1067
+ const c = cssColor(value);
1068
+ if (!c) throw new Error(`Not a color: ${JSON.stringify(value)}`);
1069
+ const solid = this._styleSolid(c);
1070
+ // spellings are unbounded too: an app animating a colour writes a new
1071
+ // string every frame
1072
+ if (this._solidByName.size >= STYLE_NAMES) this._solidByName.clear();
1073
+ this._solidByName.set(value, solid);
1074
+ return solid;
1075
+ }
1076
+
1077
+ // styleSolid by the numbers: a premultiplied [r, g, b, a]
1078
+ _styleSolid([r, g, b, a]) {
1079
+ const key = `${r}|${g}|${b}|${a}`;
1080
+ const held = this._solidPictures.get(key);
1081
+ if (held) return held;
1082
+ const lru = this._styleSolids;
1083
+ let p = lru.get(key);
1084
+ if (p) return this._usedStyle(p);
1085
+ p = this._createSolid(key, r, g, b, a);
1086
+ p._used = ++this._styleClock;
1087
+ lru.set(key, p);
1088
+ for (const [oldest, picture] of lru) {
1089
+ if (lru.size <= this._styleSolidLimit || picture === p) break;
1090
+ lru.delete(oldest);
1091
+ this._evictStyle(picture);
1092
+ }
1093
+ return p;
1094
+ }
1095
+
1096
+ /**
1097
+ * A style solid asked for again: moved to the recent end of the LRU, but
1098
+ * only once it has aged into the older half. Moved on every ask, a
1099
+ * `fillStyle` set to a string it knows cost six times what the lookup
1100
+ * does; this way it stays a lookup and a comparison, and a colour asked
1101
+ * for every frame is still not the one let go.
1102
+ */
1103
+ _usedStyle(p) {
1104
+ if (this._styleClock - p._used > this._styleSolidLimit / 2) {
1105
+ const lru = this._styleSolids;
1106
+ lru.delete(p._key);
1107
+ lru.set(p._key, p);
1108
+ p._used = ++this._styleClock;
1109
+ }
1110
+ return p;
1111
+ }
1112
+
1113
+ // a style solid let go: marked, and freed once the job has run (styleSolid)
1114
+ _evictStyle(picture) {
1115
+ picture._evicted = true;
1116
+ const evicted = this._evictedSolids;
1117
+ evicted.push(picture);
1118
+ if (evicted.length === 1) queueMicrotask(() => this._freeEvictedSolids());
1119
+ }
1120
+
1121
+ _freeEvictedSolids() {
1122
+ const evicted = this._evictedSolids;
1123
+ this._evictedSolids = [];
1124
+ for (const picture of evicted) freeSolid(picture);
1125
+ }
1126
+
1127
+ /**
1128
+ * A solid for the requests of one drawing call, and nothing after them:
1129
+ * the colours a drawing makes for itself — a style with `globalAlpha`
1130
+ * folded into it, or the alpha alone in a mask slot. A fade makes new ones
1131
+ * nearly every frame; kept the way `solidPicture` keeps its own, every
1132
+ * fade left frames × colours of them on the server until the connection
1133
+ * closed. So these are kept apart: the `TRANSIENT_SOLIDS` most recently
1134
+ * used, the least recent freed as the next is made. A drawing faded at
1135
+ * one alpha frame after frame finds its few again each time.
1136
+ *
1137
+ * Freeing one is safe because X runs a client's requests in the order
1138
+ * they were sent, so the FreePicture reaches the server after every
1139
+ * request sent with the picture before it. What that cannot cover is a
1140
+ * request sent after — hence the contract: use the solid in the requests
1141
+ * of the call that asked for it, and drop it. Never keep one or hand it
1142
+ * out. Once evicted its id goes back to the pool, and the next solid made
1143
+ * can take it, in another colour.
1144
+ *
1145
+ * A colour the app keeps anyway, for a caller (`solidPicture`) or a style
1146
+ * (`styleSolid`), is answered from there: at full opacity, a rounded
1147
+ * box's corners paint with the style's own.
1148
+ */
1149
+ transientSolid(r, g, b, a) {
1150
+ const key = `${r}|${g}|${b}|${a}`;
1151
+ let p = this._solidPictures.get(key) ?? this._styleSolids.get(key);
1152
+ if (p) return p;
1153
+ const lru = this._transientSolids;
1154
+ p = lru.get(key);
1155
+ if (p) {
1156
+ // Map iteration order is insertion order — re-insert to mark recent
1157
+ lru.delete(key);
1158
+ lru.set(key, p);
1159
+ return p;
1160
+ }
1161
+ p = this._createSolid(key, r, g, b, a);
1162
+ lru.set(key, p);
1163
+ for (const [oldest, picture] of lru) {
1164
+ if (lru.size <= this._transientSolidLimit || picture === p) break;
1165
+ lru.delete(oldest);
1166
+ freeSolid(picture);
1167
+ }
1168
+ return p;
1169
+ }
1170
+
1171
+ _createSolid(key, r, g, b, a) {
1172
+ let p;
980
1173
  const Render = this.display.Render;
981
1174
  const [major, minor] = Render.version || [0, 0];
982
1175
  if (major > 0 || minor >= 10) {
@@ -994,27 +1187,10 @@ export default class App {
994
1187
  // what it paints with, so a drawing can fold `globalAlpha` into it: glyphs
995
1188
  // composite straight from their source, with no mask slot to carry one
996
1189
  p._rgba = Object.freeze([r, g, b, a]);
997
- this._solidPictures.set(key, p);
998
- return p;
999
- }
1000
-
1001
- /**
1002
- * The solid picture a CSS colour string paints with. A UI sets the same few
1003
- * strings again on every paint — every box's background, every border —
1004
- * and the way to `solidPicture` is a parse, a premultiply and a key made of
1005
- * four numbers turned into text; by the string it is one lookup. Throws, as
1006
- * `fillStyle` always has, for a string that is not a colour.
1007
- */
1008
- solidPictureOf(value) {
1009
- let p = this._solidByName.get(value);
1010
- if (p !== undefined) return p;
1011
- const c = cssColor(value);
1012
- if (!c) throw new Error(`Not a color: ${JSON.stringify(value)}`);
1013
- p = this.solidPicture(c[0], c[1], c[2], c[3]);
1014
- // spellings are unbounded where solids are not: an app animating a
1015
- // colour writes a new string every frame
1016
- if (this._solidByName.size >= 1024) this._solidByName.clear();
1017
- this._solidByName.set(value, p);
1190
+ // and what the style LRU needs of it (styleSolid)
1191
+ p._key = key;
1192
+ p._evicted = false;
1193
+ p._used = 0;
1018
1194
  return p;
1019
1195
  }
1020
1196
 
@@ -1031,13 +1207,16 @@ export default class App {
1031
1207
  // shadow coverage is cached per connection for the same reason solids
1032
1208
  // are: the contexts that drew it are long gone (see lib/shadow.js)
1033
1209
  dropShadowSurfaces(this);
1034
- // shared by every context that ever asked (see solidPicture), so their
1035
- // lifetime is the connection's — context teardown must not free them
1036
- for (const picture of this._solidPictures.values()) {
1037
- picture.destroy();
1038
- picture._sourcePixmap?.destroy();
1039
- }
1210
+ // shared by every context that ever asked (see solidPicture), so they are
1211
+ // the app's to free, never a context's teardown; whatever is left goes
1212
+ // with the connection, the style solids this job let go of included
1213
+ for (const picture of this._solidPictures.values()) freeSolid(picture);
1214
+ for (const picture of this._styleSolids.values()) freeSolid(picture);
1215
+ for (const picture of this._transientSolids.values()) freeSolid(picture);
1216
+ this._freeEvictedSolids();
1040
1217
  this._solidPictures.clear();
1218
+ this._styleSolids.clear();
1219
+ this._transientSolids.clear();
1041
1220
  this._solidByName.clear();
1042
1221
  // GPU contexts are shared by every direct GL surface on this connection
1043
1222
  // (see renderingcontext_gles.js), so they outlive individual contexts and
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,