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 +1 -1
- package/lib/app.js +214 -35
- package/lib/image.js +125 -4
- package/lib/index.js +2 -1
- package/lib/renderingcontext_2d.js +1036 -397
- package/lib/text/layout.js +12 -6
- 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/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
|
|
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
|
|
963
|
-
* are 0..1 floats,
|
|
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:
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
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
|
-
|
|
998
|
-
|
|
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
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
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,
|