dsh-autorestart 0.8.4 → 0.8.6
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/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build `assets/icon.ico` from `assets/icon-180.png` - DIB entries only.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS AT ALL (user report, 2026-11-02: "the desktop shortcut still
|
|
6
|
+
* shows no icon", while `IconLocation` pointed at this very file and the file was
|
|
7
|
+
* there).
|
|
8
|
+
*
|
|
9
|
+
* MEASURED ON THE REAL MACHINE: the ICO that shipped was built by Pillow with
|
|
10
|
+
* PNG-compressed entries for every size (16/24/32/48/64/128/180 - all `PNG`).
|
|
11
|
+
* The classic Windows icon path - `ExtractIconEx`, `SHGetFileInfo`,
|
|
12
|
+
* `System.Drawing.Icon`, and Explorer's own image list - reads ICO entries as
|
|
13
|
+
* DIBs. Handed a PNG entry it does not fail loudly, it decodes the PNG bytes as
|
|
14
|
+
* raw pixels and paints garbage. Rendering the same 32x32 tile three ways:
|
|
15
|
+
*
|
|
16
|
+
* System.Drawing.Icon(png-entry ico) -> random noise ("+:=. .- #++ =%::*%:")
|
|
17
|
+
* System.Drawing.Icon(dib-entry ico) -> the ring + flame of the plugin's mark
|
|
18
|
+
* shell drawing a .lnk -> DIB ico -> the mark, plus the shortcut arrow
|
|
19
|
+
* shell drawing the real desktop .lnk -> a solid bright block (the bug)
|
|
20
|
+
*
|
|
21
|
+
* The control ("same .lnk format, `shell32.dll,3`") drew correctly, so the probe
|
|
22
|
+
* and the quoting are fine: PNG entries were the whole problem.
|
|
23
|
+
*
|
|
24
|
+
* WHY A SCRIPT AND NOT A ONE-OFF: an icon that can only be rebuilt by whoever
|
|
25
|
+
* happens to have Pillow installed is an icon that will drift. This reads the
|
|
26
|
+
* shipped PNG with `node:zlib` only (no dependencies), so `node scripts/build-icon.mjs`
|
|
27
|
+
* reproduces the artifact byte for byte, and the test suite can assert that.
|
|
28
|
+
*
|
|
29
|
+
* USAGE
|
|
30
|
+
* node scripts/build-icon.mjs # write assets/icon-<hash>.ico
|
|
31
|
+
* node scripts/build-icon.mjs --check # compare with the shipped file, do not write
|
|
32
|
+
*
|
|
33
|
+
* WHY THE FILE NAME CARRIES A HASH (user report, 2026-11-02, the second half of
|
|
34
|
+
* "still no icon"). Explorer caches the icon it drew PER ICON FILE PATH. Once it
|
|
35
|
+
* had cached "...\assets\icon.ico,0" while that file was the broken all-PNG one,
|
|
36
|
+
* fixing the file changed nothing: rewriting the shortcut, even deleting and
|
|
37
|
+
* recreating it, kept showing the cached garbage - measured, not guessed. A new
|
|
38
|
+
* PATH is a new cache entry, so the shipped name embeds the content hash and an
|
|
39
|
+
* update that changes the artwork necessarily changes the path.
|
|
40
|
+
*
|
|
41
|
+
* Sizes are capped at 180 (the artwork's own size): a 256 entry is the one size
|
|
42
|
+
* a DIB cannot carry, and a PNG entry for it would bring the whole bug back.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import fs from 'node:fs'
|
|
46
|
+
import path from 'node:path'
|
|
47
|
+
import crypto from 'node:crypto'
|
|
48
|
+
import zlib from 'node:zlib'
|
|
49
|
+
import { fileURLToPath } from 'node:url'
|
|
50
|
+
|
|
51
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
52
|
+
const PLUGIN_DIR = path.dirname(HERE)
|
|
53
|
+
export const SOURCE_PNG = path.join(PLUGIN_DIR, 'assets', 'icon-180.png')
|
|
54
|
+
export const ASSETS_DIR = path.join(PLUGIN_DIR, 'assets')
|
|
55
|
+
|
|
56
|
+
/** The shipped icon's name carries its content: a new build is a new cache key. */
|
|
57
|
+
export function iconFileNameFor(bytes) {
|
|
58
|
+
const digest = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8)
|
|
59
|
+
return 'icon-' + digest + '.ico'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where the icon for this artwork belongs. */
|
|
63
|
+
export function targetIcoPath(bytes) {
|
|
64
|
+
return path.join(ASSETS_DIR, iconFileNameFor(bytes))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The icon file the plugin currently ships (the newest hashed one). */
|
|
68
|
+
export function shippedIcoPath() {
|
|
69
|
+
let names = []
|
|
70
|
+
try {
|
|
71
|
+
names = fs.readdirSync(ASSETS_DIR)
|
|
72
|
+
} catch {
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
const hashed = names.filter((name) => /^icon-[0-9a-f]{8}\.ico$/i.test(name)).sort()
|
|
76
|
+
return hashed.length > 0 ? path.join(ASSETS_DIR, hashed[hashed.length - 1]) : ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The sizes the shell asks for, small to large. */
|
|
80
|
+
export const ICON_SIZES = Object.freeze([16, 24, 32, 48, 64, 128, 180])
|
|
81
|
+
|
|
82
|
+
/* ------------------------------------------------------------------ *
|
|
83
|
+
* PNG reading (node:zlib only - no dependencies, no native modules)
|
|
84
|
+
* ------------------------------------------------------------------ */
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decode an 8-bit RGBA (colour type 6) PNG into `{ width, height, data }`.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately narrow: the artwork this project ships is an 8-bit RGBA PNG, and
|
|
90
|
+
* guessing at every PNG variant here would be a second image library. Anything
|
|
91
|
+
* else is refused with a sentence instead of a wrong icon.
|
|
92
|
+
*
|
|
93
|
+
* @param {Buffer} buffer
|
|
94
|
+
* @returns {{ width: number, height: number, data: Buffer }}
|
|
95
|
+
*/
|
|
96
|
+
export function decodePng(buffer) {
|
|
97
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 8) throw new Error('not a PNG: too short')
|
|
98
|
+
if (buffer.readUInt32BE(0) !== 0x89504e47 || buffer.readUInt32BE(4) !== 0x0d0a1a0a) {
|
|
99
|
+
throw new Error('not a PNG: bad signature')
|
|
100
|
+
}
|
|
101
|
+
let offset = 8
|
|
102
|
+
let width = 0
|
|
103
|
+
let height = 0
|
|
104
|
+
let bitDepth = 0
|
|
105
|
+
let colorType = 0
|
|
106
|
+
const idat = []
|
|
107
|
+
while (offset + 8 <= buffer.length) {
|
|
108
|
+
const length = buffer.readUInt32BE(offset)
|
|
109
|
+
const type = buffer.toString('ascii', offset + 4, offset + 8)
|
|
110
|
+
const start = offset + 8
|
|
111
|
+
const end = start + length
|
|
112
|
+
if (end > buffer.length) throw new Error('not a PNG: truncated chunk ' + type)
|
|
113
|
+
if (type === 'IHDR') {
|
|
114
|
+
width = buffer.readUInt32BE(start)
|
|
115
|
+
height = buffer.readUInt32BE(start + 4)
|
|
116
|
+
bitDepth = buffer[start + 8]
|
|
117
|
+
colorType = buffer[start + 9]
|
|
118
|
+
const interlace = buffer[start + 12]
|
|
119
|
+
if (bitDepth !== 8) throw new Error('unsupported PNG: bit depth ' + bitDepth + ' (need 8)')
|
|
120
|
+
if (colorType !== 6) throw new Error('unsupported PNG: colour type ' + colorType + ' (need 6 = RGBA)')
|
|
121
|
+
if (interlace !== 0) throw new Error('unsupported PNG: interlaced')
|
|
122
|
+
} else if (type === 'IDAT') {
|
|
123
|
+
idat.push(buffer.subarray(start, end))
|
|
124
|
+
} else if (type === 'IEND') {
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
offset = end + 4 // + CRC
|
|
128
|
+
}
|
|
129
|
+
if (width <= 0 || height <= 0) throw new Error('not a PNG: no IHDR')
|
|
130
|
+
if (idat.length === 0) throw new Error('not a PNG: no IDAT')
|
|
131
|
+
const raw = zlib.inflateSync(Buffer.concat(idat))
|
|
132
|
+
const stride = width * 4
|
|
133
|
+
if (raw.length < (stride + 1) * height) throw new Error('not a PNG: image data is short')
|
|
134
|
+
const out = Buffer.alloc(width * height * 4)
|
|
135
|
+
let previous = Buffer.alloc(stride)
|
|
136
|
+
for (let y = 0; y < height; y += 1) {
|
|
137
|
+
const filter = raw[y * (stride + 1)]
|
|
138
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
|
|
139
|
+
const current = Buffer.alloc(stride)
|
|
140
|
+
for (let x = 0; x < stride; x += 1) {
|
|
141
|
+
const rawByte = line[x]
|
|
142
|
+
const left = x >= 4 ? current[x - 4] : 0
|
|
143
|
+
const up = previous[x]
|
|
144
|
+
const upLeft = x >= 4 ? previous[x - 4] : 0
|
|
145
|
+
let value
|
|
146
|
+
switch (filter) {
|
|
147
|
+
case 0: value = rawByte; break
|
|
148
|
+
case 1: value = rawByte + left; break
|
|
149
|
+
case 2: value = rawByte + up; break
|
|
150
|
+
case 3: value = rawByte + ((left + up) >> 1); break
|
|
151
|
+
case 4: {
|
|
152
|
+
const p = left + up - upLeft
|
|
153
|
+
const pa = Math.abs(p - left)
|
|
154
|
+
const pb = Math.abs(p - up)
|
|
155
|
+
const pc = Math.abs(p - upLeft)
|
|
156
|
+
const predictor = pa <= pb && pa <= pc ? left : (pb <= pc ? up : upLeft)
|
|
157
|
+
value = rawByte + predictor
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
default: throw new Error('not a PNG: unknown filter ' + filter + ' on row ' + y)
|
|
161
|
+
}
|
|
162
|
+
current[x] = value & 0xff
|
|
163
|
+
}
|
|
164
|
+
current.copy(out, y * stride)
|
|
165
|
+
previous = current
|
|
166
|
+
}
|
|
167
|
+
return { width, height, data: out }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Box-filter downscale to a square size. Averaging is what makes a 180 px mark
|
|
172
|
+
* legible at 16 px; nearest-neighbour turns the flame into a smear.
|
|
173
|
+
*
|
|
174
|
+
* @param {{ width: number, height: number, data: Buffer }} image
|
|
175
|
+
* @param {number} size
|
|
176
|
+
* @returns {Buffer} size*size RGBA
|
|
177
|
+
*/
|
|
178
|
+
export function resizeSquare(image, size) {
|
|
179
|
+
const { width, height, data } = image
|
|
180
|
+
const out = Buffer.alloc(size * size * 4)
|
|
181
|
+
for (let y = 0; y < size; y += 1) {
|
|
182
|
+
const y0 = Math.floor((y * height) / size)
|
|
183
|
+
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / size))
|
|
184
|
+
for (let x = 0; x < size; x += 1) {
|
|
185
|
+
const x0 = Math.floor((x * width) / size)
|
|
186
|
+
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / size))
|
|
187
|
+
let r = 0
|
|
188
|
+
let g = 0
|
|
189
|
+
let b = 0
|
|
190
|
+
let a = 0
|
|
191
|
+
let weight = 0
|
|
192
|
+
for (let sy = y0; sy < y1; sy += 1) {
|
|
193
|
+
for (let sx = x0; sx < x1; sx += 1) {
|
|
194
|
+
const at = (sy * width + sx) * 4
|
|
195
|
+
// PREMULTIPLIED WEIGHT: transparent pixels must not darken the edge.
|
|
196
|
+
const alpha = data[at + 3] / 255
|
|
197
|
+
r += data[at] * alpha
|
|
198
|
+
g += data[at + 1] * alpha
|
|
199
|
+
b += data[at + 2] * alpha
|
|
200
|
+
a += alpha
|
|
201
|
+
weight += 1
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const at = (y * size + x) * 4
|
|
205
|
+
if (a > 0) {
|
|
206
|
+
out[at] = Math.round(r / a)
|
|
207
|
+
out[at + 1] = Math.round(g / a)
|
|
208
|
+
out[at + 2] = Math.round(b / a)
|
|
209
|
+
}
|
|
210
|
+
out[at + 3] = Math.round((a / Math.max(1, weight)) * 255)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ------------------------------------------------------------------ *
|
|
217
|
+
* ICO writing - DIB (BITMAPINFOHEADER + BGRA) entries only
|
|
218
|
+
* ------------------------------------------------------------------ */
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* One DIB entry: BITMAPINFOHEADER + bottom-up BGRA rows.
|
|
222
|
+
*
|
|
223
|
+
* NO AND MASK, ON PURPOSE - and this was MEASURED, not assumed. With a 32-bpp
|
|
224
|
+
* entry the alpha channel carries the transparency; the AND mask is the legacy
|
|
225
|
+
* 1-bit form for the same job, and when both are present this Windows build
|
|
226
|
+
* applies the mask over the alpha and draws a ghost (the mark reduced to its
|
|
227
|
+
* outline). The three builds, each in its own shortcut so the shell had no cache
|
|
228
|
+
* to reuse, rendered:
|
|
229
|
+
*
|
|
230
|
+
* mask omitted -> the ring + flame (the artwork) <- this build
|
|
231
|
+
* mask = alpha<128 -> outline only, everything else blank
|
|
232
|
+
* mask = inverted -> outline only, everything else blank
|
|
233
|
+
*
|
|
234
|
+
* Pillow's ICO writer also omits the mask for 32-bpp entries (its 32 px entry is
|
|
235
|
+
* 40 + 32*32*4 bytes, exactly what this writes), which is why its files always
|
|
236
|
+
* rendered correctly and mine did not.
|
|
237
|
+
*
|
|
238
|
+
* @param {Buffer} rgba size*size RGBA
|
|
239
|
+
* @param {number} size
|
|
240
|
+
* @returns {Buffer}
|
|
241
|
+
*/
|
|
242
|
+
export function encodeDibEntry(rgba, size) {
|
|
243
|
+
const header = Buffer.alloc(40)
|
|
244
|
+
header.writeUInt32LE(40, 0) // biSize
|
|
245
|
+
header.writeInt32LE(size, 4) // biWidth
|
|
246
|
+
header.writeInt32LE(size * 2, 8) // biHeight (XOR + AND), the icon convention
|
|
247
|
+
header.writeUInt16LE(1, 12) // biPlanes
|
|
248
|
+
header.writeUInt16LE(32, 14) // biBitCount
|
|
249
|
+
header.writeUInt32LE(0, 16) // BI_RGB
|
|
250
|
+
header.writeUInt32LE(size * size * 4, 20) // biSizeImage
|
|
251
|
+
const pixels = Buffer.alloc(size * size * 4)
|
|
252
|
+
for (let y = 0; y < size; y += 1) {
|
|
253
|
+
// Bottom-up, as every DIB is.
|
|
254
|
+
const sourceY = size - 1 - y
|
|
255
|
+
for (let x = 0; x < size; x += 1) {
|
|
256
|
+
const from = (sourceY * size + x) * 4
|
|
257
|
+
const to = (y * size + x) * 4
|
|
258
|
+
pixels[to] = rgba[from + 2] // B
|
|
259
|
+
pixels[to + 1] = rgba[from + 1] // G
|
|
260
|
+
pixels[to + 2] = rgba[from] // R
|
|
261
|
+
pixels[to + 3] = rgba[from + 3] // A
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return Buffer.concat([header, pixels])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build the whole `.ico` from an RGBA image.
|
|
269
|
+
*
|
|
270
|
+
* @param {{ width: number, height: number, data: Buffer }} image
|
|
271
|
+
* @param {number[]} [sizes]
|
|
272
|
+
* @returns {Buffer}
|
|
273
|
+
*/
|
|
274
|
+
export function buildIco(image, sizes = ICON_SIZES) {
|
|
275
|
+
const entries = sizes.map((size) => ({ size, payload: encodeDibEntry(resizeSquare(image, size), size) }))
|
|
276
|
+
const header = Buffer.alloc(6)
|
|
277
|
+
header.writeUInt16LE(0, 0) // reserved
|
|
278
|
+
header.writeUInt16LE(1, 2) // 1 = icon
|
|
279
|
+
header.writeUInt16LE(entries.length, 4)
|
|
280
|
+
const directory = Buffer.alloc(entries.length * 16)
|
|
281
|
+
let offset = 6 + entries.length * 16
|
|
282
|
+
entries.forEach((entry, index) => {
|
|
283
|
+
const at = index * 16
|
|
284
|
+
directory[at] = entry.size >= 256 ? 0 : entry.size // width (0 = 256)
|
|
285
|
+
directory[at + 1] = entry.size >= 256 ? 0 : entry.size // height
|
|
286
|
+
directory[at + 2] = 0 // colour count (0 = truecolour)
|
|
287
|
+
directory[at + 3] = 0 // reserved
|
|
288
|
+
directory.writeUInt16LE(1, at + 4) // planes
|
|
289
|
+
directory.writeUInt16LE(32, at + 6) // bits per pixel
|
|
290
|
+
directory.writeUInt32LE(entry.payload.length, at + 8)
|
|
291
|
+
directory.writeUInt32LE(offset, at + 12)
|
|
292
|
+
offset += entry.payload.length
|
|
293
|
+
})
|
|
294
|
+
return Buffer.concat([header, directory, ...entries.map((entry) => entry.payload)])
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Read the artwork and build the icon, from disk. */
|
|
298
|
+
export function buildIconFromDisk() {
|
|
299
|
+
return buildIco(decodePng(fs.readFileSync(SOURCE_PNG)))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ------------------------------------------------------------------ *
|
|
303
|
+
* A MINIMAL TRUETYPE READER (2026-11-02)
|
|
304
|
+
*
|
|
305
|
+
* WHY THIS EXISTS AT ALL: the approved shortcut icon is the DSH artwork with a
|
|
306
|
+
* round restore badge in the corner, and that badge carries the glyph U+21BB
|
|
307
|
+
* (CLOCKWISE OPEN CIRCLE ARROW) in Segoe UI Symbol. Drawing that glyph needs the
|
|
308
|
+
* font outline, and this generator may not take a dependency (no canvas, no
|
|
309
|
+
* browser, no ImageMagick). So the small part of TrueType that one glyph needs is
|
|
310
|
+
* read here: `cmap` (formats 4 and 12) to find the glyph, `loca` + `glyf` for the
|
|
311
|
+
* outline, and the contours are filled with a nonzero-winding scanline.
|
|
312
|
+
*
|
|
313
|
+
* WHAT IT DOES NOT DO: hinting, variations, colour fonts, kerning, shaping. One
|
|
314
|
+
* glyph, no text layout, no instruction execution.
|
|
315
|
+
* ------------------------------------------------------------------ */
|
|
316
|
+
|
|
317
|
+
/** Read the table directory of a .ttf/.otf and hand back a tag -> {offset} map. */
|
|
318
|
+
export function readTtfDirectory(buffer) {
|
|
319
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) throw new Error('not a font: too short')
|
|
320
|
+
const tag = buffer.toString('ascii', 0, 4)
|
|
321
|
+
if (tag !== '\u0000\u0001\u0000\u0000' && tag !== 'true' && tag !== 'OTTO') {
|
|
322
|
+
throw new Error('not a TrueType/OpenType font: sfnt tag ' + JSON.stringify(tag))
|
|
323
|
+
}
|
|
324
|
+
const numTables = buffer.readUInt16BE(4)
|
|
325
|
+
const tables = new Map()
|
|
326
|
+
for (let index = 0; index < numTables; index += 1) {
|
|
327
|
+
const at = 12 + index * 16
|
|
328
|
+
tables.set(buffer.toString('ascii', at, at + 4), {
|
|
329
|
+
offset: buffer.readUInt32BE(at + 8),
|
|
330
|
+
length: buffer.readUInt32BE(at + 12),
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
return tables
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The glyph index for one code point, from `cmap` (formats 4 and 12). */
|
|
337
|
+
export function glyphIndexFor(buffer, tables, codePoint) {
|
|
338
|
+
const cmap = tables.get('cmap')
|
|
339
|
+
if (!cmap) throw new Error('the font has no cmap')
|
|
340
|
+
const count = buffer.readUInt16BE(cmap.offset + 2)
|
|
341
|
+
let best = null
|
|
342
|
+
for (let index = 0; index < count; index += 1) {
|
|
343
|
+
const at = cmap.offset + 4 + index * 8
|
|
344
|
+
const platform = buffer.readUInt16BE(at)
|
|
345
|
+
const encoding = buffer.readUInt16BE(at + 2)
|
|
346
|
+
const subtable = cmap.offset + buffer.readUInt32BE(at + 4)
|
|
347
|
+
const format = buffer.readUInt16BE(subtable)
|
|
348
|
+
// (3,1)/(3,10) are what Windows uses; (0,x) is the Unicode platform.
|
|
349
|
+
const score = (platform === 3 && encoding === 10 && format === 12) ? 4
|
|
350
|
+
: (platform === 3 && encoding === 1 && format === 4) ? 3
|
|
351
|
+
: (platform === 0 && (format === 4 || format === 12)) ? 1
|
|
352
|
+
: 0
|
|
353
|
+
if (score === 0) continue
|
|
354
|
+
if (best === null || score > best.score) best = { score, subtable, format }
|
|
355
|
+
}
|
|
356
|
+
if (best === null) throw new Error('the font has no Unicode cmap subtable')
|
|
357
|
+
if (best.format === 12) {
|
|
358
|
+
const groups = buffer.readUInt32BE(best.subtable + 12)
|
|
359
|
+
for (let index = 0; index < groups; index += 1) {
|
|
360
|
+
const at = best.subtable + 16 + index * 12
|
|
361
|
+
const start = buffer.readUInt32BE(at)
|
|
362
|
+
const end = buffer.readUInt32BE(at + 4)
|
|
363
|
+
if (codePoint >= start && codePoint <= end) return buffer.readUInt32BE(at + 8) + (codePoint - start)
|
|
364
|
+
}
|
|
365
|
+
return 0
|
|
366
|
+
}
|
|
367
|
+
const segCountX2 = buffer.readUInt16BE(best.subtable + 6)
|
|
368
|
+
const endCodes = best.subtable + 14
|
|
369
|
+
const startCodes = endCodes + segCountX2 + 2
|
|
370
|
+
const idDeltas = startCodes + segCountX2
|
|
371
|
+
const idRangeOffsets = idDeltas + segCountX2
|
|
372
|
+
for (let index = 0; index < segCountX2 / 2; index += 1) {
|
|
373
|
+
const end = buffer.readUInt16BE(endCodes + index * 2)
|
|
374
|
+
if (codePoint > end) continue
|
|
375
|
+
const start = buffer.readUInt16BE(startCodes + index * 2)
|
|
376
|
+
if (codePoint < start) return 0
|
|
377
|
+
const delta = buffer.readInt16BE(idDeltas + index * 2)
|
|
378
|
+
const rangeOffset = buffer.readUInt16BE(idRangeOffsets + index * 2)
|
|
379
|
+
if (rangeOffset === 0) return (codePoint + delta) & 0xffff
|
|
380
|
+
const glyphAt = idRangeOffsets + index * 2 + rangeOffset + (codePoint - start) * 2
|
|
381
|
+
if (glyphAt + 1 >= buffer.length) return 0
|
|
382
|
+
const glyph = buffer.readUInt16BE(glyphAt)
|
|
383
|
+
return glyph === 0 ? 0 : (glyph + delta) & 0xffff
|
|
384
|
+
}
|
|
385
|
+
return 0
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Everything one glyph needs, from the font tables. */
|
|
389
|
+
export function readFont(buffer) {
|
|
390
|
+
const tables = readTtfDirectory(buffer)
|
|
391
|
+
const head = tables.get('head')
|
|
392
|
+
const maxp = tables.get('maxp')
|
|
393
|
+
const hhea = tables.get('hhea')
|
|
394
|
+
const hmtx = tables.get('hmtx')
|
|
395
|
+
const loca = tables.get('loca')
|
|
396
|
+
const glyf = tables.get('glyf')
|
|
397
|
+
if (!head || !maxp || !hhea || !hmtx || !loca || !glyf) throw new Error('the font is missing a required table')
|
|
398
|
+
return {
|
|
399
|
+
buffer,
|
|
400
|
+
tables,
|
|
401
|
+
unitsPerEm: buffer.readUInt16BE(head.offset + 18),
|
|
402
|
+
longOffsets: buffer.readInt16BE(head.offset + 50) === 1,
|
|
403
|
+
numGlyphs: buffer.readUInt16BE(maxp.offset + 4),
|
|
404
|
+
numberOfHMetrics: buffer.readUInt16BE(hhea.offset + 34),
|
|
405
|
+
hmtx,
|
|
406
|
+
loca,
|
|
407
|
+
glyf,
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The outline of one glyph as contours of {x, y, onCurve}, in font units. */
|
|
412
|
+
export function readGlyphOutline(font, glyphIndex, depth = 0) {
|
|
413
|
+
if (depth > 8) return []
|
|
414
|
+
if (!Number.isFinite(glyphIndex) || glyphIndex < 0 || glyphIndex >= font.numGlyphs) return []
|
|
415
|
+
const buffer = font.buffer
|
|
416
|
+
const locaAt = font.loca.offset + (font.longOffsets ? glyphIndex * 4 : glyphIndex * 2)
|
|
417
|
+
const start = font.longOffsets ? buffer.readUInt32BE(locaAt) : buffer.readUInt16BE(locaAt) * 2
|
|
418
|
+
const endAt = font.loca.offset + (font.longOffsets ? (glyphIndex + 1) * 4 : (glyphIndex + 1) * 2)
|
|
419
|
+
const end = font.longOffsets ? buffer.readUInt32BE(endAt) : buffer.readUInt16BE(endAt) * 2
|
|
420
|
+
if (end <= start) return []
|
|
421
|
+
const at = font.glyf.offset + start
|
|
422
|
+
const contours = buffer.readInt16BE(at)
|
|
423
|
+
if (contours < 0) {
|
|
424
|
+
// A composite glyph: place the components and let recursion do the rest.
|
|
425
|
+
const out = []
|
|
426
|
+
let cursor = at + 10
|
|
427
|
+
for (let guard = 0; guard < 32; guard += 1) {
|
|
428
|
+
const flags = buffer.readUInt16BE(cursor)
|
|
429
|
+
const componentGlyph = buffer.readUInt16BE(cursor + 2)
|
|
430
|
+
cursor += 4
|
|
431
|
+
const words = (flags & 0x0001) !== 0
|
|
432
|
+
let dx = 0
|
|
433
|
+
let dy = 0
|
|
434
|
+
if (flags & 0x0002) {
|
|
435
|
+
if (words) { dx = buffer.readInt16BE(cursor); dy = buffer.readInt16BE(cursor + 2); cursor += 4 }
|
|
436
|
+
else { dx = buffer.readInt8(cursor); dy = buffer.readInt8(cursor + 1); cursor += 2 }
|
|
437
|
+
} else {
|
|
438
|
+
cursor += words ? 4 : 2 // point matching is not needed for one glyph
|
|
439
|
+
}
|
|
440
|
+
if (flags & 0x0008) cursor += 2
|
|
441
|
+
else if (flags & 0x0040) cursor += 4
|
|
442
|
+
else if (flags & 0x0080) cursor += 8
|
|
443
|
+
for (const contour of readGlyphOutline(font, componentGlyph, depth + 1)) {
|
|
444
|
+
out.push(contour.map((point) => ({ x: point.x + dx, y: point.y + dy, onCurve: point.onCurve })))
|
|
445
|
+
}
|
|
446
|
+
if ((flags & 0x0020) === 0) break
|
|
447
|
+
}
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
if (contours === 0) return []
|
|
451
|
+
let cursor = at + 10
|
|
452
|
+
const endPoints = []
|
|
453
|
+
for (let index = 0; index < contours; index += 1) {
|
|
454
|
+
endPoints.push(buffer.readUInt16BE(cursor))
|
|
455
|
+
cursor += 2
|
|
456
|
+
}
|
|
457
|
+
const instructionLength = buffer.readUInt16BE(cursor)
|
|
458
|
+
cursor += 2 + instructionLength
|
|
459
|
+
const pointCount = endPoints[endPoints.length - 1] + 1
|
|
460
|
+
const flags = []
|
|
461
|
+
while (flags.length < pointCount) {
|
|
462
|
+
const flag = buffer[cursor]
|
|
463
|
+
cursor += 1
|
|
464
|
+
flags.push(flag)
|
|
465
|
+
if (flag & 0x08) {
|
|
466
|
+
const repeat = buffer[cursor]
|
|
467
|
+
cursor += 1
|
|
468
|
+
for (let extra = 0; extra < repeat; extra += 1) flags.push(flag)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const xs = []
|
|
472
|
+
let x = 0
|
|
473
|
+
for (const flag of flags) {
|
|
474
|
+
if (flag & 0x02) {
|
|
475
|
+
const short = buffer[cursor]
|
|
476
|
+
cursor += 1
|
|
477
|
+
x += (flag & 0x10) ? short : -short
|
|
478
|
+
} else if ((flag & 0x10) === 0) {
|
|
479
|
+
x += buffer.readInt16BE(cursor)
|
|
480
|
+
cursor += 2
|
|
481
|
+
}
|
|
482
|
+
xs.push(x)
|
|
483
|
+
}
|
|
484
|
+
const ys = []
|
|
485
|
+
let y = 0
|
|
486
|
+
for (const flag of flags) {
|
|
487
|
+
if (flag & 0x04) {
|
|
488
|
+
const short = buffer[cursor]
|
|
489
|
+
cursor += 1
|
|
490
|
+
y += (flag & 0x20) ? short : -short
|
|
491
|
+
} else if ((flag & 0x20) === 0) {
|
|
492
|
+
y += buffer.readInt16BE(cursor)
|
|
493
|
+
cursor += 2
|
|
494
|
+
}
|
|
495
|
+
ys.push(y)
|
|
496
|
+
}
|
|
497
|
+
const out = []
|
|
498
|
+
let first = 0
|
|
499
|
+
for (const last of endPoints) {
|
|
500
|
+
const contour = []
|
|
501
|
+
for (let index = first; index <= last; index += 1) {
|
|
502
|
+
contour.push({ x: xs[index], y: ys[index], onCurve: (flags[index] & 0x01) !== 0 })
|
|
503
|
+
}
|
|
504
|
+
if (contour.length > 0) out.push(contour)
|
|
505
|
+
first = last + 1
|
|
506
|
+
}
|
|
507
|
+
return out
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Flatten quadratic contours into polylines (font units).
|
|
512
|
+
*
|
|
513
|
+
* The TrueType rule for a run of off-curve points: each consecutive pair implies an
|
|
514
|
+
* ON-curve midpoint. A contour that starts with an off-curve point starts at the
|
|
515
|
+
* midpoint of the last and first point.
|
|
516
|
+
*/
|
|
517
|
+
export function flattenContours(contours) {
|
|
518
|
+
const polylines = []
|
|
519
|
+
for (const contour of contours) {
|
|
520
|
+
if (contour.length === 0) continue
|
|
521
|
+
const points = contour.slice()
|
|
522
|
+
let startPoint
|
|
523
|
+
if (points[0].onCurve) {
|
|
524
|
+
startPoint = points[0]
|
|
525
|
+
points.push(points.shift())
|
|
526
|
+
} else if (points[points.length - 1].onCurve) {
|
|
527
|
+
startPoint = points[points.length - 1]
|
|
528
|
+
} else {
|
|
529
|
+
const last = points[points.length - 1]
|
|
530
|
+
startPoint = { x: (last.x + points[0].x) / 2, y: (last.y + points[0].y) / 2, onCurve: true }
|
|
531
|
+
}
|
|
532
|
+
const line = [{ x: startPoint.x, y: startPoint.y }]
|
|
533
|
+
let current = { x: startPoint.x, y: startPoint.y }
|
|
534
|
+
const queue = points.slice()
|
|
535
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
536
|
+
const point = queue[index]
|
|
537
|
+
if (point.onCurve) {
|
|
538
|
+
line.push({ x: point.x, y: point.y })
|
|
539
|
+
current = { x: point.x, y: point.y }
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
const next = queue[index + 1] ?? { x: startPoint.x, y: startPoint.y, onCurve: true }
|
|
543
|
+
const end = next.onCurve
|
|
544
|
+
? { x: next.x, y: next.y }
|
|
545
|
+
: { x: (point.x + next.x) / 2, y: (point.y + next.y) / 2 }
|
|
546
|
+
const steps = 8 // fine enough that the curve reads as a curve
|
|
547
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
548
|
+
const t = step / steps
|
|
549
|
+
const mt = 1 - t
|
|
550
|
+
line.push({
|
|
551
|
+
x: mt * mt * current.x + 2 * mt * t * point.x + t * t * end.x,
|
|
552
|
+
y: mt * mt * current.y + 2 * mt * t * point.y + t * t * end.y,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
current = end
|
|
556
|
+
if (!next.onCurve) index += 1 // the implied midpoint was just consumed
|
|
557
|
+
}
|
|
558
|
+
if (line.length >= 3) polylines.push(line)
|
|
559
|
+
}
|
|
560
|
+
return polylines
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Fill polylines into a coverage buffer (0 or 255 per sample), nonzero winding. */
|
|
564
|
+
export function fillPolylines(polylines, width, height, { coverage = null } = {}) {
|
|
565
|
+
const buffer = coverage ?? new Uint8Array(width * height)
|
|
566
|
+
const edges = []
|
|
567
|
+
for (const line of polylines) {
|
|
568
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
569
|
+
const from = line[index]
|
|
570
|
+
const to = line[(index + 1) % line.length]
|
|
571
|
+
if (from.y === to.y) continue
|
|
572
|
+
edges.push({ x0: from.x, y0: from.y, x1: to.x, y1: to.y })
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
for (let y = 0; y < height; y += 1) {
|
|
576
|
+
const sampleY = y + 0.5
|
|
577
|
+
const crossings = []
|
|
578
|
+
for (const edge of edges) {
|
|
579
|
+
const minY = Math.min(edge.y0, edge.y1)
|
|
580
|
+
const maxY = Math.max(edge.y0, edge.y1)
|
|
581
|
+
if (sampleY < minY || sampleY >= maxY) continue
|
|
582
|
+
const t = (sampleY - edge.y0) / (edge.y1 - edge.y0)
|
|
583
|
+
crossings.push({ x: edge.x0 + t * (edge.x1 - edge.x0), winding: edge.y1 > edge.y0 ? 1 : -1 })
|
|
584
|
+
}
|
|
585
|
+
if (crossings.length === 0) continue
|
|
586
|
+
crossings.sort((left, right) => left.x - right.x)
|
|
587
|
+
let winding = 0
|
|
588
|
+
for (let index = 0; index < crossings.length; index += 1) {
|
|
589
|
+
const previous = crossings[index - 1]
|
|
590
|
+
const from = index === 0 ? 0 : Math.ceil(previous.x - 0.5)
|
|
591
|
+
const to = Math.ceil(crossings[index].x - 0.5)
|
|
592
|
+
if (winding !== 0) {
|
|
593
|
+
for (let x = Math.max(0, from); x < Math.min(width, to); x += 1) buffer[y * width + x] = 255
|
|
594
|
+
}
|
|
595
|
+
winding += crossings[index].winding
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return buffer
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* ------------------------------------------------------------------ *
|
|
602
|
+
* THE RESCUE ICON: THE DSH ARTWORK WITH A RESTORE BADGE (user decision)
|
|
603
|
+
*
|
|
604
|
+
* The user looked at a 256 px preview of exactly this and said "use that one", so
|
|
605
|
+
* the numbers below are a TRANSCRIPTION of the approved picture, not a new design.
|
|
606
|
+
* Every size is DRAWN at that size (supersampled 4x, then box-downsampled); a 16 px
|
|
607
|
+
* badge scaled down from a 256 px bitmap is a grey smudge, which is why the cache
|
|
608
|
+
* rule in the task says "seven sizes, each drawn directly".
|
|
609
|
+
* ------------------------------------------------------------------ */
|
|
610
|
+
|
|
611
|
+
/** The geometry the user approved, as fractions of the icon edge. */
|
|
612
|
+
export const RESCUE_LAYOUT = Object.freeze({
|
|
613
|
+
discRatio: 0.375, // badge disc diameter
|
|
614
|
+
marginRatio: 0.039, // disc bottom-right to canvas edge
|
|
615
|
+
ringRatio: 0.047, // white ring: disc plus this, on the diameter
|
|
616
|
+
glyphRatio: 0.203, // glyph size
|
|
617
|
+
glyphLiftRatio: 0.012, // the glyph sits this much above the optical centre
|
|
618
|
+
fill: Object.freeze({ r: 0x25, g: 0x63, b: 0xeb }), // #2563EB
|
|
619
|
+
ink: Object.freeze({ r: 0xff, g: 0xff, b: 0xff }),
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
/** How many samples per output pixel the badge is drawn with. */
|
|
623
|
+
export const RESCUE_SUPERSAMPLE = 4
|
|
624
|
+
|
|
625
|
+
/** A filled circle into a coverage buffer, in sample-grid coordinates. */
|
|
626
|
+
function fillCircle(coverage, size, cx, cy, radius) {
|
|
627
|
+
const minX = Math.max(0, Math.floor(cx - radius - 1))
|
|
628
|
+
const maxX = Math.min(size - 1, Math.ceil(cx + radius + 1))
|
|
629
|
+
const minY = Math.max(0, Math.floor(cy - radius - 1))
|
|
630
|
+
const maxY = Math.min(size - 1, Math.ceil(cy + radius + 1))
|
|
631
|
+
const r2 = radius * radius
|
|
632
|
+
for (let y = minY; y <= maxY; y += 1) {
|
|
633
|
+
const dy = y + 0.5 - cy
|
|
634
|
+
for (let x = minX; x <= maxX; x += 1) {
|
|
635
|
+
const dx = x + 0.5 - cx
|
|
636
|
+
if (dx * dx + dy * dy <= r2) coverage[y * size + x] = 255
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return coverage
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Draw the badge onto a transparent RGBA canvas of `size` x `size`.
|
|
644
|
+
*
|
|
645
|
+
* @param {number} size
|
|
646
|
+
* @param {{ glyphPolylines?: Array<Array<{x:number,y:number}>>, fontUnitsPerEm?: number }} [options]
|
|
647
|
+
* @returns {Buffer} size*size RGBA
|
|
648
|
+
*/
|
|
649
|
+
export function drawRescueBadge(size, { glyphPolylines = [], fontUnitsPerEm = 2048 } = {}) {
|
|
650
|
+
const samples = size * RESCUE_SUPERSAMPLE
|
|
651
|
+
const disc = RESCUE_LAYOUT.discRatio * size
|
|
652
|
+
const margin = RESCUE_LAYOUT.marginRatio * size
|
|
653
|
+
const ring = disc + RESCUE_LAYOUT.ringRatio * size
|
|
654
|
+
const cx = (size - margin - disc / 2) * RESCUE_SUPERSAMPLE
|
|
655
|
+
const cy = (size - margin - disc / 2) * RESCUE_SUPERSAMPLE
|
|
656
|
+
const ringCoverage = new Uint8Array(samples * samples)
|
|
657
|
+
const discCoverage = new Uint8Array(samples * samples)
|
|
658
|
+
fillCircle(ringCoverage, samples, cx, cy, (ring / 2) * RESCUE_SUPERSAMPLE)
|
|
659
|
+
fillCircle(discCoverage, samples, cx, cy, (disc / 2) * RESCUE_SUPERSAMPLE)
|
|
660
|
+
|
|
661
|
+
// THE GLYPH, scaled into the disc and drawn several times a hair apart: that is
|
|
662
|
+
// the synthetic bold "Segoe UI Symbol Bold" stands for (the family ships no bold
|
|
663
|
+
// face), and it is what the approved preview shows.
|
|
664
|
+
const glyphCoverage = new Uint8Array(samples * samples)
|
|
665
|
+
if (Array.isArray(glyphPolylines) && glyphPolylines.length > 0) {
|
|
666
|
+
const targetPx = RESCUE_LAYOUT.glyphRatio * size
|
|
667
|
+
const scale = (targetPx / fontUnitsPerEm) * RESCUE_SUPERSAMPLE
|
|
668
|
+
let minX = Infinity
|
|
669
|
+
let minY = Infinity
|
|
670
|
+
let maxX = -Infinity
|
|
671
|
+
let maxY = -Infinity
|
|
672
|
+
for (const line of glyphPolylines) {
|
|
673
|
+
for (const point of line) {
|
|
674
|
+
if (point.x < minX) minX = point.x
|
|
675
|
+
if (point.x > maxX) maxX = point.x
|
|
676
|
+
if (point.y < minY) minY = point.y
|
|
677
|
+
if (point.y > maxY) maxY = point.y
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (Number.isFinite(minX) && maxX > minX) {
|
|
681
|
+
const inkWidth = (maxX - minX) * scale
|
|
682
|
+
const inkHeight = (maxY - minY) * scale
|
|
683
|
+
const left = cx - inkWidth / 2 - minX * scale
|
|
684
|
+
const bottom = cy + inkHeight / 2 + minY * scale // font Y grows up, the grid down
|
|
685
|
+
const lift = RESCUE_LAYOUT.glyphLiftRatio * size * RESCUE_SUPERSAMPLE
|
|
686
|
+
const bold = Math.max(0.5, 0.022 * targetPx * RESCUE_SUPERSAMPLE)
|
|
687
|
+
for (const shift of [-bold, 0, bold]) {
|
|
688
|
+
const placed = glyphPolylines.map((line) => line.map((point) => ({
|
|
689
|
+
x: point.x * scale + left + shift,
|
|
690
|
+
y: bottom - point.y * scale - lift,
|
|
691
|
+
})))
|
|
692
|
+
fillPolylines(placed, samples, samples, { coverage: glyphCoverage })
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Composite in sample space: ring (white), disc (blue), glyph (white), then
|
|
698
|
+
// box-downsample to the output size - the same anti-aliasing the base uses.
|
|
699
|
+
const canvas = Buffer.alloc(size * size * 4)
|
|
700
|
+
const per = RESCUE_SUPERSAMPLE * RESCUE_SUPERSAMPLE
|
|
701
|
+
for (let y = 0; y < size; y += 1) {
|
|
702
|
+
for (let x = 0; x < size; x += 1) {
|
|
703
|
+
let r = 0
|
|
704
|
+
let g = 0
|
|
705
|
+
let b = 0
|
|
706
|
+
let a = 0
|
|
707
|
+
for (let sy = 0; sy < RESCUE_SUPERSAMPLE; sy += 1) {
|
|
708
|
+
for (let sx = 0; sx < RESCUE_SUPERSAMPLE; sx += 1) {
|
|
709
|
+
const sample = (y * RESCUE_SUPERSAMPLE + sy) * samples + (x * RESCUE_SUPERSAMPLE + sx)
|
|
710
|
+
let sr = 0
|
|
711
|
+
let sg = 0
|
|
712
|
+
let sb = 0
|
|
713
|
+
let sa = 0
|
|
714
|
+
if (ringCoverage[sample] !== 0) {
|
|
715
|
+
sr = RESCUE_LAYOUT.ink.r; sg = RESCUE_LAYOUT.ink.g; sb = RESCUE_LAYOUT.ink.b; sa = 255
|
|
716
|
+
}
|
|
717
|
+
if (discCoverage[sample] !== 0) {
|
|
718
|
+
sr = RESCUE_LAYOUT.fill.r; sg = RESCUE_LAYOUT.fill.g; sb = RESCUE_LAYOUT.fill.b; sa = 255
|
|
719
|
+
}
|
|
720
|
+
if (glyphCoverage[sample] !== 0) {
|
|
721
|
+
sr = RESCUE_LAYOUT.ink.r; sg = RESCUE_LAYOUT.ink.g; sb = RESCUE_LAYOUT.ink.b; sa = 255
|
|
722
|
+
}
|
|
723
|
+
r += sr; g += sg; b += sb; a += sa
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const at = (y * size + x) * 4
|
|
727
|
+
canvas[at] = Math.round(r / per)
|
|
728
|
+
canvas[at + 1] = Math.round(g / per)
|
|
729
|
+
canvas[at + 2] = Math.round(b / per)
|
|
730
|
+
canvas[at + 3] = Math.round(a / per)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return canvas
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Alpha-composite `top` (size*size RGBA) over `bottom` (same size). */
|
|
737
|
+
export function compositeOver(bottom, top) {
|
|
738
|
+
const out = Buffer.from(bottom)
|
|
739
|
+
for (let at = 0; at < out.length; at += 4) {
|
|
740
|
+
const topAlpha = top[at + 3] / 255
|
|
741
|
+
if (topAlpha <= 0) continue
|
|
742
|
+
const bottomAlpha = out[at + 3] / 255
|
|
743
|
+
const outAlpha = topAlpha + bottomAlpha * (1 - topAlpha)
|
|
744
|
+
if (outAlpha <= 0) continue
|
|
745
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
746
|
+
const value = (top[at + channel] * topAlpha + out[at + channel] * bottomAlpha * (1 - topAlpha)) / outAlpha
|
|
747
|
+
out[at + channel] = Math.round(Math.min(255, Math.max(0, value)))
|
|
748
|
+
}
|
|
749
|
+
out[at + 3] = Math.round(outAlpha * 255)
|
|
750
|
+
}
|
|
751
|
+
return out
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The whole rescue set: the artwork with the badge, one bitmap per size, each drawn
|
|
756
|
+
* at its own size (never scaled from a bigger one).
|
|
757
|
+
*
|
|
758
|
+
* @param {{ width: number, height: number, data: Buffer }} artwork
|
|
759
|
+
* @param {Array<Array<{x:number,y:number}>>} glyphPolylines in font units
|
|
760
|
+
* @param {number} unitsPerEm
|
|
761
|
+
* @param {{ sizes?: number[] }} [options]
|
|
762
|
+
* @returns {Map<number, Buffer>} size -> size*size RGBA
|
|
763
|
+
*/
|
|
764
|
+
export function buildRescueBitmaps(artwork, glyphPolylines, unitsPerEm, { sizes = RESCUE_SIZES } = {}) {
|
|
765
|
+
const bitmaps = new Map()
|
|
766
|
+
for (const size of sizes) {
|
|
767
|
+
const base = resizeSquare(artwork, size)
|
|
768
|
+
const badge = drawRescueBadge(size, { glyphPolylines, fontUnitsPerEm: unitsPerEm })
|
|
769
|
+
bitmaps.set(size, compositeOver(base, badge))
|
|
770
|
+
}
|
|
771
|
+
return bitmaps
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Encode an RGBA bitmap as a PNG (node:zlib is enough; no dependencies). */
|
|
775
|
+
export function encodePng(rgba, width, height) {
|
|
776
|
+
const raw = Buffer.alloc((width * 4 + 1) * height)
|
|
777
|
+
for (let y = 0; y < height; y += 1) {
|
|
778
|
+
raw[y * (width * 4 + 1)] = 0 // filter: none
|
|
779
|
+
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4)
|
|
780
|
+
}
|
|
781
|
+
const crc32 = (buffer) => {
|
|
782
|
+
let crc = 0xffffffff
|
|
783
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
784
|
+
crc ^= buffer[index]
|
|
785
|
+
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
|
|
786
|
+
}
|
|
787
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
788
|
+
}
|
|
789
|
+
const chunk = (type, data) => {
|
|
790
|
+
const length = Buffer.alloc(4)
|
|
791
|
+
length.writeUInt32BE(data.length, 0)
|
|
792
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
|
793
|
+
const crc = Buffer.alloc(4)
|
|
794
|
+
crc.writeUInt32BE(crc32(body), 0)
|
|
795
|
+
return Buffer.concat([length, body, crc])
|
|
796
|
+
}
|
|
797
|
+
const ihdr = Buffer.alloc(13)
|
|
798
|
+
ihdr.writeUInt32BE(width, 0)
|
|
799
|
+
ihdr.writeUInt32BE(height, 4)
|
|
800
|
+
ihdr[8] = 8 // bit depth
|
|
801
|
+
ihdr[9] = 6 // colour type: RGBA
|
|
802
|
+
return Buffer.concat([
|
|
803
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
804
|
+
chunk('IHDR', ihdr),
|
|
805
|
+
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
|
|
806
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
807
|
+
])
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/* ------------------------------------------------------------------ *
|
|
811
|
+
* THE RESCUE ICON SET, ON DISK
|
|
812
|
+
*
|
|
813
|
+
* NAMING (deliberate): the sha8 is taken from the ICO bytes - the artifact the
|
|
814
|
+
* desktop actually uses - and BOTH files carry it, so `icon-rescue-<sha8>.ico` and
|
|
815
|
+
* `icon-rescue-<sha8>.png` are two renderings of one build. The bytes the settings
|
|
816
|
+
* page shows are the 48 px PNG rendered by the same code, and `iconDataUriHash`
|
|
817
|
+
* states that PNG's own sha8, so "the page and the shortcut are the same drawing"
|
|
818
|
+
* is checkable instead of asserted.
|
|
819
|
+
* ------------------------------------------------------------------ */
|
|
820
|
+
|
|
821
|
+
/** The seven sizes the shell asks for, plus the 256 the page previews. */
|
|
822
|
+
export const RESCUE_ICO_SIZES = Object.freeze([16, 24, 32, 48, 64, 128, 180])
|
|
823
|
+
export const RESCUE_SIZES = Object.freeze([...RESCUE_ICO_SIZES, 256])
|
|
824
|
+
/** The size whose pixels the settings page is given. */
|
|
825
|
+
export const RESCUE_PAGE_SIZE = 48
|
|
826
|
+
/** The size whose pixels ship as the preview PNG. */
|
|
827
|
+
export const RESCUE_PREVIEW_SIZE = 256
|
|
828
|
+
|
|
829
|
+
/** The DSH artwork the user approved as the base (never the exe's 32 px bitmap). */
|
|
830
|
+
export const RESCUE_ARTWORK = 'C:/Users/Administrator/AppData/Local/Programs/DeepSeek Harness/resources/icon.png'
|
|
831
|
+
/** The font the approved preview used for U+21BB. */
|
|
832
|
+
export const RESCUE_FONT = path.join(process.env.WINDIR || 'C:/Windows', 'Fonts', 'seguisym.ttf')
|
|
833
|
+
/** The code point of the glyph on the badge. */
|
|
834
|
+
export const RESCUE_GLYPH = 0x21bb
|
|
835
|
+
|
|
836
|
+
/** Every drawing input the rescue icon needs, read from disk. */
|
|
837
|
+
export function readRescueSources({ artworkPath = RESCUE_ARTWORK, fontPath = RESCUE_FONT } = {}) {
|
|
838
|
+
const artwork = decodePng(fs.readFileSync(artworkPath))
|
|
839
|
+
const font = readFont(fs.readFileSync(fontPath))
|
|
840
|
+
const glyphIndex = glyphIndexFor(font.buffer, font.tables, RESCUE_GLYPH)
|
|
841
|
+
if (glyphIndex === 0) throw new Error('the font has no glyph for U+21BB: ' + fontPath)
|
|
842
|
+
const glyphPolylines = flattenContours(readGlyphOutline(font, glyphIndex))
|
|
843
|
+
if (glyphPolylines.length === 0) throw new Error('the U+21BB glyph in ' + fontPath + ' has an empty outline')
|
|
844
|
+
return { artwork, glyphPolylines, unitsPerEm: font.unitsPerEm }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Build the whole rescue set from disk.
|
|
849
|
+
*
|
|
850
|
+
* @returns {{ ico: Buffer, png: Buffer, page: Buffer, sizes: number[] }}
|
|
851
|
+
*/
|
|
852
|
+
export function buildRescueFromDisk(options = {}) {
|
|
853
|
+
const { artwork, glyphPolylines, unitsPerEm } = readRescueSources(options)
|
|
854
|
+
const bitmaps = buildRescueBitmaps(artwork, glyphPolylines, unitsPerEm)
|
|
855
|
+
const entries = RESCUE_ICO_SIZES.map((size) => ({ size, rgba: bitmaps.get(size) }))
|
|
856
|
+
const ico = buildIcoFromBitmaps(entries)
|
|
857
|
+
return {
|
|
858
|
+
ico,
|
|
859
|
+
png: encodePng(bitmaps.get(RESCUE_PREVIEW_SIZE), RESCUE_PREVIEW_SIZE, RESCUE_PREVIEW_SIZE),
|
|
860
|
+
page: encodePng(bitmaps.get(RESCUE_PAGE_SIZE), RESCUE_PAGE_SIZE, RESCUE_PAGE_SIZE),
|
|
861
|
+
sizes: RESCUE_SIZES.slice(),
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** An ICO from ready-made bitmaps - DIB entries, no AND mask (see encodeDibEntry). */
|
|
866
|
+
export function buildIcoFromBitmaps(entries) {
|
|
867
|
+
const payloads = entries.map((entry) => ({ size: entry.size, payload: encodeDibEntry(entry.rgba, entry.size) }))
|
|
868
|
+
const header = Buffer.alloc(6)
|
|
869
|
+
header.writeUInt16LE(0, 0)
|
|
870
|
+
header.writeUInt16LE(1, 2)
|
|
871
|
+
header.writeUInt16LE(payloads.length, 4)
|
|
872
|
+
const directory = Buffer.alloc(payloads.length * 16)
|
|
873
|
+
let offset = 6 + payloads.length * 16
|
|
874
|
+
payloads.forEach((entry, index) => {
|
|
875
|
+
const at = index * 16
|
|
876
|
+
directory[at] = entry.size >= 256 ? 0 : entry.size
|
|
877
|
+
directory[at + 1] = entry.size >= 256 ? 0 : entry.size
|
|
878
|
+
directory[at + 2] = 0
|
|
879
|
+
directory[at + 3] = 0
|
|
880
|
+
directory.writeUInt16LE(1, at + 4)
|
|
881
|
+
directory.writeUInt16LE(32, at + 6)
|
|
882
|
+
directory.writeUInt32LE(entry.payload.length, at + 8)
|
|
883
|
+
directory.writeUInt32LE(offset, at + 12)
|
|
884
|
+
offset += entry.payload.length
|
|
885
|
+
})
|
|
886
|
+
return Buffer.concat([header, directory, ...payloads.map((entry) => entry.payload)])
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The sha8 both rescue files are named after: the ICO's own content. */
|
|
890
|
+
export function rescueHashOf(icoBytes) {
|
|
891
|
+
return crypto.createHash('sha256').update(icoBytes).digest('hex').slice(0, 8)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Where this build's rescue files belong. */
|
|
895
|
+
export function rescueAssetPaths(icoBytes) {
|
|
896
|
+
const hash = rescueHashOf(icoBytes)
|
|
897
|
+
return {
|
|
898
|
+
hash,
|
|
899
|
+
ico: path.join(ASSETS_DIR, 'icon-rescue-' + hash + '.ico'),
|
|
900
|
+
png: path.join(ASSETS_DIR, 'icon-rescue-' + hash + '.png'),
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** The rescue files currently shipped (newest hashed name), or nulls. */
|
|
905
|
+
export function shippedRescuePaths() {
|
|
906
|
+
let names = []
|
|
907
|
+
try {
|
|
908
|
+
names = fs.readdirSync(ASSETS_DIR)
|
|
909
|
+
} catch {
|
|
910
|
+
return { ico: '', png: '' }
|
|
911
|
+
}
|
|
912
|
+
const ico = names.filter((name) => /^icon-rescue-[0-9a-f]{8}\.ico$/i.test(name)).sort()
|
|
913
|
+
const png = names.filter((name) => /^icon-rescue-[0-9a-f]{8}\.png$/i.test(name)).sort()
|
|
914
|
+
return {
|
|
915
|
+
ico: ico.length > 0 ? path.join(ASSETS_DIR, ico[ico.length - 1]) : '',
|
|
916
|
+
png: png.length > 0 ? path.join(ASSETS_DIR, png[png.length - 1]) : '',
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/* ------------------------------------------------------------------ *
|
|
921
|
+
* CLI
|
|
922
|
+
* ------------------------------------------------------------------ */
|
|
923
|
+
|
|
924
|
+
const invokedDirectly = process.argv[1] !== undefined
|
|
925
|
+
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
|
|
926
|
+
|
|
927
|
+
if (invokedDirectly) {
|
|
928
|
+
const checkOnly = process.argv.includes('--check')
|
|
929
|
+
// TWO ICONS, TWO MODES. `--rescue` builds the one the user approved (artwork +
|
|
930
|
+
// restore badge); without it the plain artwork is rebuilt, which is what the
|
|
931
|
+
// package used before and what the settings page shows as the plugin's own mark.
|
|
932
|
+
const rescue = process.argv.includes('--rescue')
|
|
933
|
+
const built = rescue ? buildRescueFromDisk() : { ico: buildIconFromDisk(), png: null }
|
|
934
|
+
if (rescue) {
|
|
935
|
+
const target = rescueAssetPaths(built.ico)
|
|
936
|
+
if (checkOnly) {
|
|
937
|
+
const shipped = shippedRescuePaths()
|
|
938
|
+
if (shipped.ico === '') {
|
|
939
|
+
console.error('no assets/icon-rescue-<hash>.ico is shipped; run: node scripts/build-icon.mjs --rescue')
|
|
940
|
+
process.exit(1)
|
|
941
|
+
}
|
|
942
|
+
if (path.basename(shipped.ico) !== path.basename(target.ico)) {
|
|
943
|
+
console.error('the shipped rescue icon is stale: ' + path.basename(shipped.ico)
|
|
944
|
+
+ ' (expected ' + path.basename(target.ico) + ')')
|
|
945
|
+
console.error('run: node scripts/build-icon.mjs --rescue')
|
|
946
|
+
process.exit(1)
|
|
947
|
+
}
|
|
948
|
+
const pngOnDisk = fs.existsSync(shipped.png) ? fs.readFileSync(shipped.png) : null
|
|
949
|
+
if (pngOnDisk === null || !pngOnDisk.equals(built.png)) {
|
|
950
|
+
console.error('the shipped rescue preview PNG is missing or stale: ' + path.basename(shipped.png))
|
|
951
|
+
process.exit(1)
|
|
952
|
+
}
|
|
953
|
+
console.log('assets/' + path.basename(shipped.ico) + ' + ' + path.basename(shipped.png)
|
|
954
|
+
+ ' match the approved drawing (' + built.ico.length + ' bytes, '
|
|
955
|
+
+ RESCUE_ICO_SIZES.length + ' DIB entries)')
|
|
956
|
+
process.exit(0)
|
|
957
|
+
}
|
|
958
|
+
// ONE RESCUE ICON AT A TIME: a stale sibling keeps the old cache key alive.
|
|
959
|
+
for (const name of fs.readdirSync(ASSETS_DIR)) {
|
|
960
|
+
if (!/^icon-rescue-[0-9a-f]{8}\.(ico|png)$/i.test(name)) continue
|
|
961
|
+
if (name === path.basename(target.ico) || name === path.basename(target.png)) continue
|
|
962
|
+
fs.rmSync(path.join(ASSETS_DIR, name), { force: true })
|
|
963
|
+
console.log('removed the superseded rescue icon: ' + name)
|
|
964
|
+
}
|
|
965
|
+
fs.writeFileSync(target.ico, built.ico)
|
|
966
|
+
fs.writeFileSync(target.png, built.png)
|
|
967
|
+
console.log('wrote ' + target.ico + ' (' + built.ico.length + ' bytes, '
|
|
968
|
+
+ RESCUE_ICO_SIZES.length + ' DIB entries: ' + RESCUE_ICO_SIZES.join('/') + ')')
|
|
969
|
+
console.log('wrote ' + target.png + ' (' + built.png.length + ' bytes, '
|
|
970
|
+
+ RESCUE_PREVIEW_SIZE + ' px preview)')
|
|
971
|
+
process.exit(0)
|
|
972
|
+
}
|
|
973
|
+
const target = targetIcoPath(built.ico)
|
|
974
|
+
if (checkOnly) {
|
|
975
|
+
const shipped = shippedIcoPath()
|
|
976
|
+
if (shipped === '') {
|
|
977
|
+
console.error('no assets/icon-<hash>.ico is shipped; run: node scripts/build-icon.mjs')
|
|
978
|
+
process.exit(1)
|
|
979
|
+
}
|
|
980
|
+
if (path.basename(shipped) !== path.basename(target)) {
|
|
981
|
+
console.error('the shipped icon is stale: ' + path.basename(shipped) + ' (expected ' + path.basename(target) + ')')
|
|
982
|
+
console.error('run: node scripts/build-icon.mjs')
|
|
983
|
+
process.exit(1)
|
|
984
|
+
}
|
|
985
|
+
console.log('assets/' + path.basename(shipped) + ' matches the artwork (' + built.ico.length + ' bytes, all DIB entries)')
|
|
986
|
+
process.exit(0)
|
|
987
|
+
}
|
|
988
|
+
// ONE ICON AT A TIME: a stale sibling would keep the old cache key alive.
|
|
989
|
+
for (const name of fs.readdirSync(ASSETS_DIR)) {
|
|
990
|
+
if (!/^icon(-[0-9a-f]{8})?\.ico$/i.test(name)) continue
|
|
991
|
+
const full = path.join(ASSETS_DIR, name)
|
|
992
|
+
if (path.basename(target) === name) continue
|
|
993
|
+
fs.rmSync(full, { force: true })
|
|
994
|
+
console.log('removed the superseded icon: ' + name)
|
|
995
|
+
}
|
|
996
|
+
fs.writeFileSync(target, built.ico)
|
|
997
|
+
console.log('wrote ' + target + ' (' + built.ico.length + ' bytes, ' + ICON_SIZES.length + ' DIB entries: ' + ICON_SIZES.join('/') + ')')
|
|
998
|
+
}
|