@hankchen/canvas-image-renderer 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +50 -2
- package/dist/index.d.ts +289 -0
- package/dist/index.js +835 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// src/backend.ts
|
|
2
|
+
import { createCanvas as createCanvas3 } from "@napi-rs/canvas";
|
|
3
|
+
|
|
4
|
+
// src/color.ts
|
|
5
|
+
function parseColor(s) {
|
|
6
|
+
const h = s.startsWith("#") ? s.slice(1) : s;
|
|
7
|
+
let hex;
|
|
8
|
+
switch (h.length) {
|
|
9
|
+
case 3:
|
|
10
|
+
case 4: {
|
|
11
|
+
hex = Array.from(h, (ch) => ch + ch).join("");
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
case 6:
|
|
15
|
+
case 8:
|
|
16
|
+
hex = h;
|
|
17
|
+
break;
|
|
18
|
+
default:
|
|
19
|
+
throw new Error(`\u65E0\u6CD5\u89E3\u6790\u989C\u8272 ${JSON.stringify(s)}: \u652F\u6301 #rgb/#rgba/#rrggbb/#rrggbbaa`);
|
|
20
|
+
}
|
|
21
|
+
const c = [0, 0, 0, 0];
|
|
22
|
+
for (let i = 0; i < hex.length; i += 1) {
|
|
23
|
+
c[i / 2 | 0] = c[i / 2 | 0] * 16 + hexDigit(hex[i], s);
|
|
24
|
+
}
|
|
25
|
+
if (hex.length === 6) {
|
|
26
|
+
c[3] = 255;
|
|
27
|
+
}
|
|
28
|
+
return { r: c[0], g: c[1], b: c[2], a: c[3] };
|
|
29
|
+
}
|
|
30
|
+
function hexDigit(ch, source) {
|
|
31
|
+
const code = ch.charCodeAt(0);
|
|
32
|
+
if (code >= 48 && code <= 57) return code - 48;
|
|
33
|
+
if (code >= 97 && code <= 102) return code - 97 + 10;
|
|
34
|
+
if (code >= 65 && code <= 70) return code - 65 + 10;
|
|
35
|
+
throw new Error(`\u65E0\u6CD5\u89E3\u6790\u989C\u8272 ${JSON.stringify(source)}: \u975E\u6CD5\u5341\u516D\u8FDB\u5236\u4F4D ${JSON.stringify(ch)}`);
|
|
36
|
+
}
|
|
37
|
+
function cssColor(c) {
|
|
38
|
+
return `rgba(${c.r},${c.g},${c.b},${c.a / 255})`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/decode.ts
|
|
42
|
+
import { Image, loadImage } from "@napi-rs/canvas";
|
|
43
|
+
import { readFileSync } from "fs";
|
|
44
|
+
function loadSourceBytes(src, registry) {
|
|
45
|
+
const hit = registry?.get(src);
|
|
46
|
+
if (hit) {
|
|
47
|
+
return hit;
|
|
48
|
+
}
|
|
49
|
+
if (src.startsWith("data:")) {
|
|
50
|
+
const comma = src.indexOf(",");
|
|
51
|
+
if (comma < 0 || !src.slice(0, comma).endsWith(";base64")) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return new Uint8Array(Buffer.from(src.slice(comma + 1), "base64"));
|
|
55
|
+
}
|
|
56
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(src)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return new Uint8Array(readFileSync(src));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function decodeHeaderSize(bytes) {
|
|
66
|
+
const img = new Image();
|
|
67
|
+
img.src = Buffer.from(bytes);
|
|
68
|
+
if (img.width <= 0 || img.height <= 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return { width: img.width, height: img.height };
|
|
72
|
+
}
|
|
73
|
+
async function decodeDrawableImage(bytes) {
|
|
74
|
+
try {
|
|
75
|
+
return await loadImage(Buffer.from(bytes));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/exif.ts
|
|
82
|
+
function exifOrientation(data) {
|
|
83
|
+
if (data.length < 4 || data[0] !== 255 || data[1] !== 216) {
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
87
|
+
let pos = 2;
|
|
88
|
+
while (pos + 4 <= data.length && data[pos] === 255) {
|
|
89
|
+
const marker = data[pos + 1];
|
|
90
|
+
if (marker === 255) {
|
|
91
|
+
pos += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (marker === 216 || marker === 1 || marker === 217) {
|
|
95
|
+
pos += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const segLen = view.getUint16(pos + 2);
|
|
99
|
+
if (segLen < 2 || pos + 2 + segLen > data.length) {
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
if (marker === 218) {
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (marker === 225 && segLen >= 8 && isExifHeader(data, pos + 4)) {
|
|
106
|
+
return parseTIFFOrientation(data.subarray(pos + 10, pos + 2 + segLen));
|
|
107
|
+
}
|
|
108
|
+
pos += 2 + segLen;
|
|
109
|
+
}
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
function isExifHeader(data, offset) {
|
|
113
|
+
return data[offset] === 69 && data[offset + 1] === 120 && data[offset + 2] === 105 && data[offset + 3] === 102 && data[offset + 4] === 0 && data[offset + 5] === 0;
|
|
114
|
+
}
|
|
115
|
+
function parseTIFFOrientation(tiff) {
|
|
116
|
+
if (tiff.length < 8) {
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
const le = tiff[0] === 73 && tiff[1] === 73;
|
|
120
|
+
const be = tiff[0] === 77 && tiff[1] === 77;
|
|
121
|
+
if (!le && !be) {
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const view = new DataView(tiff.buffer, tiff.byteOffset, tiff.byteLength);
|
|
125
|
+
const u16 = (o) => view.getUint16(o, le);
|
|
126
|
+
const u32 = (o) => view.getUint32(o, le);
|
|
127
|
+
if (u16(2) !== 42) {
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
const ifd0 = u32(4);
|
|
131
|
+
if (ifd0 < 8 || ifd0 + 2 > tiff.length) {
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const entries = u16(ifd0);
|
|
135
|
+
for (let i = 0; i < entries; i += 1) {
|
|
136
|
+
const e = ifd0 + 2 + i * 12;
|
|
137
|
+
if (e + 12 > tiff.length) {
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
if (u16(e) !== 274) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
let v;
|
|
144
|
+
switch (u16(e + 2)) {
|
|
145
|
+
// 字段类型:3 SHORT / 4 LONG,值内联于条目
|
|
146
|
+
case 3:
|
|
147
|
+
v = u16(e + 8);
|
|
148
|
+
break;
|
|
149
|
+
case 4:
|
|
150
|
+
v = u32(e + 8);
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
return v >= 1 && v <= 8 ? v : 1;
|
|
156
|
+
}
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
function canonicalSourceRect(raw, orientation, sw, sh) {
|
|
160
|
+
const { x, y, width, height } = raw;
|
|
161
|
+
switch (orientation) {
|
|
162
|
+
case 2:
|
|
163
|
+
return { x: sw - x - width, y, width, height };
|
|
164
|
+
case 3:
|
|
165
|
+
return { x: sw - x - width, y: sh - y - height, width, height };
|
|
166
|
+
case 4:
|
|
167
|
+
return { x, y: sh - y - height, width, height };
|
|
168
|
+
case 5:
|
|
169
|
+
return { x: y, y: x, width: height, height: width };
|
|
170
|
+
case 6:
|
|
171
|
+
return { x: sh - y - height, y: x, width: height, height: width };
|
|
172
|
+
case 7:
|
|
173
|
+
return { x: sh - y - height, y: sw - x - width, width: height, height: width };
|
|
174
|
+
case 8:
|
|
175
|
+
return { x: y, y: sw - x - width, width: height, height: width };
|
|
176
|
+
default:
|
|
177
|
+
return { x, y, width, height };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function rawDimensions(canonicalWidth, canonicalHeight, orientation) {
|
|
181
|
+
return orientation >= 5 ? { width: canonicalHeight, height: canonicalWidth } : { width: canonicalWidth, height: canonicalHeight };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/typography.ts
|
|
185
|
+
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
|
|
186
|
+
import { createHash } from "crypto";
|
|
187
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
188
|
+
import { join } from "path";
|
|
189
|
+
import { tmpdir } from "os";
|
|
190
|
+
import { createHeuristicMeasurer } from "@hankchen/canvas";
|
|
191
|
+
|
|
192
|
+
// src/url.ts
|
|
193
|
+
function isRemoteURL(s) {
|
|
194
|
+
try {
|
|
195
|
+
const url = new URL(s);
|
|
196
|
+
return url.protocol !== "" && url.host !== "";
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function isNumeric(s) {
|
|
202
|
+
if (s === "") return false;
|
|
203
|
+
return Number.isFinite(Number(s));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/typography.ts
|
|
207
|
+
function isBuiltinFont(font) {
|
|
208
|
+
return font === "" || isNumeric(font);
|
|
209
|
+
}
|
|
210
|
+
function assertNotTtc(data, ref) {
|
|
211
|
+
if (data.length >= 4 && data[0] === 116 && data[1] === 116 && data[2] === 99 && data[3] === 102) {
|
|
212
|
+
throw new Error(`\u5B57\u4F53 ${ref} \u4E3A TTC/OTC \u96C6\u5408: \u672C\u6E32\u67D3\u7AEF fail-fast \u62D2\u6536\uFF08\u9B54\u6570 ttcf\uFF1B\u591A\u9762 CJK \u96C6\u5408\u9759\u9ED8\u53D6\u9ED8\u8BA4\u9762\u662F\u6700\u5371\u9669\u7684\u9759\u9ED8\u9519\u8BEF\uFF09`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function fontAlias(data) {
|
|
216
|
+
return `ncv-${createHash("sha256").update(data).digest("hex").slice(0, 24)}`;
|
|
217
|
+
}
|
|
218
|
+
var registeredFonts = /* @__PURE__ */ new Map();
|
|
219
|
+
function fontCacheDir() {
|
|
220
|
+
const dir = join(tmpdir(), "js-canvas-image-renderer-fonts");
|
|
221
|
+
if (!existsSync(dir)) {
|
|
222
|
+
mkdirSync(dir, { recursive: true });
|
|
223
|
+
}
|
|
224
|
+
return dir;
|
|
225
|
+
}
|
|
226
|
+
function loadFontFamilyByPath(path, ref) {
|
|
227
|
+
const cached = registeredFonts.get(ref);
|
|
228
|
+
if (cached !== void 0) {
|
|
229
|
+
return cached;
|
|
230
|
+
}
|
|
231
|
+
let data;
|
|
232
|
+
try {
|
|
233
|
+
data = new Uint8Array(readFileSync2(path));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new Error(`\u8BFB\u53D6\u5B57\u4F53 ${ref}: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
return registerFontBytes(data, ref);
|
|
238
|
+
}
|
|
239
|
+
function registerFontBytes(data, ref) {
|
|
240
|
+
assertNotTtc(data, ref);
|
|
241
|
+
const alias = fontAlias(data);
|
|
242
|
+
if (GlobalFonts.has(alias)) {
|
|
243
|
+
registeredFonts.set(ref, alias);
|
|
244
|
+
return alias;
|
|
245
|
+
}
|
|
246
|
+
const file = join(fontCacheDir(), `${alias}.font`);
|
|
247
|
+
if (!existsSync(file)) {
|
|
248
|
+
writeFileSync(file, data);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
GlobalFonts.registerFromPath(file, alias);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new Error(`\u89E3\u6790\u5B57\u4F53 ${ref}: ${error.message}`);
|
|
254
|
+
}
|
|
255
|
+
if (!GlobalFonts.has(alias)) {
|
|
256
|
+
throw new Error(`\u89E3\u6790\u5B57\u4F53 ${ref}: \u6CE8\u518C\u540E\u672A\u547D\u4E2D\u522B\u540D\uFF08\u975E sfnt \u5B57\u4F53\u6587\u4EF6?\uFF09`);
|
|
257
|
+
}
|
|
258
|
+
registeredFonts.set(ref, alias);
|
|
259
|
+
return alias;
|
|
260
|
+
}
|
|
261
|
+
function registerFontData(ref, data) {
|
|
262
|
+
registerFontBytes(data, ref);
|
|
263
|
+
}
|
|
264
|
+
function loadFontFamily(font) {
|
|
265
|
+
if (isBuiltinFont(font)) {
|
|
266
|
+
return "sans-serif";
|
|
267
|
+
}
|
|
268
|
+
return loadFontFamilyByPath(font, font);
|
|
269
|
+
}
|
|
270
|
+
var measuringCtx = null;
|
|
271
|
+
function ensureMeasuringCtx() {
|
|
272
|
+
if (measuringCtx === null) {
|
|
273
|
+
measuringCtx = createCanvas(16, 16).getContext("2d");
|
|
274
|
+
}
|
|
275
|
+
return measuringCtx;
|
|
276
|
+
}
|
|
277
|
+
var createNapiMeasurerFactory = (font, fontSize) => {
|
|
278
|
+
let family;
|
|
279
|
+
try {
|
|
280
|
+
if (!Number.isFinite(fontSize) || fontSize <= 0) {
|
|
281
|
+
return createHeuristicMeasurer(fontSize);
|
|
282
|
+
}
|
|
283
|
+
family = loadFontFamily(font);
|
|
284
|
+
} catch {
|
|
285
|
+
return createHeuristicMeasurer(fontSize);
|
|
286
|
+
}
|
|
287
|
+
const ctx = ensureMeasuringCtx();
|
|
288
|
+
const shorthand = `${fontSize}px ${family}`;
|
|
289
|
+
return {
|
|
290
|
+
measure(text) {
|
|
291
|
+
ctx.font = shorthand;
|
|
292
|
+
return ctx.measureText(text).width;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// src/text.ts
|
|
298
|
+
import { createCanvas as createCanvas2 } from "@napi-rs/canvas";
|
|
299
|
+
function baselineOffset(metrics, verticalAlign) {
|
|
300
|
+
const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
|
|
301
|
+
const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
|
|
302
|
+
switch (verticalAlign) {
|
|
303
|
+
case "center":
|
|
304
|
+
return (ascent - descent) / 2;
|
|
305
|
+
case "bottom":
|
|
306
|
+
return -descent;
|
|
307
|
+
default:
|
|
308
|
+
return ascent;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function penOffset(width, horizontalAlign) {
|
|
312
|
+
switch (horizontalAlign) {
|
|
313
|
+
case "center":
|
|
314
|
+
return -width / 2;
|
|
315
|
+
case "right":
|
|
316
|
+
return -width;
|
|
317
|
+
default:
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function drawTextLine(ctx, line, x, y, options) {
|
|
322
|
+
if (line === "") {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let fill;
|
|
326
|
+
try {
|
|
327
|
+
fill = cssColor(parseColor(options.fontColor));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
throw new Error(`\u89E3\u6790\u5B57\u8272: ${error.message}`);
|
|
330
|
+
}
|
|
331
|
+
const family = loadFontFamily(options.font);
|
|
332
|
+
const shorthand = `${options.fontSize}px ${family}`;
|
|
333
|
+
ctx.font = shorthand;
|
|
334
|
+
ctx.textAlign = "left";
|
|
335
|
+
ctx.textBaseline = "alphabetic";
|
|
336
|
+
const metrics = ctx.measureText(line);
|
|
337
|
+
const width = metrics.width;
|
|
338
|
+
const penX = penOffset(width, options.horizontalAlign);
|
|
339
|
+
const baselineY = baselineOffset(metrics, options.verticalAlign);
|
|
340
|
+
if (options.angle === 0) {
|
|
341
|
+
ctx.fillStyle = fill;
|
|
342
|
+
ctx.fillText(line, x + penX, y + baselineY);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
drawRotatedText(ctx, line, fill, shorthand, metrics, x, y, options.angle, penX, baselineY);
|
|
346
|
+
}
|
|
347
|
+
function drawRotatedText(ctx, line, fill, shorthand, metrics, x, y, angle, penX, baselineY) {
|
|
348
|
+
const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
|
|
349
|
+
const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
|
|
350
|
+
const boxHeight = ascent + descent;
|
|
351
|
+
const margin = 2 + Math.ceil(boxHeight / 16);
|
|
352
|
+
const offscreen = createCanvas2(Math.ceil(metrics.width) + 2 * margin, Math.ceil(boxHeight) + 2 * margin);
|
|
353
|
+
const offCtx = offscreen.getContext("2d");
|
|
354
|
+
offCtx.font = shorthand;
|
|
355
|
+
offCtx.fillStyle = fill;
|
|
356
|
+
offCtx.textAlign = "left";
|
|
357
|
+
offCtx.textBaseline = "alphabetic";
|
|
358
|
+
offCtx.fillText(line, margin, margin + ascent);
|
|
359
|
+
const ax = margin - penX;
|
|
360
|
+
const ay = margin + ascent - baselineY;
|
|
361
|
+
const rad = angle * Math.PI / 180;
|
|
362
|
+
const cos = Math.cos(rad);
|
|
363
|
+
const sin = Math.sin(rad);
|
|
364
|
+
ctx.save();
|
|
365
|
+
ctx.translate(x, y);
|
|
366
|
+
ctx.transform(cos, -sin, sin, cos, -ax, -ay);
|
|
367
|
+
ctx.drawImage(offscreen, 0, 0);
|
|
368
|
+
ctx.restore();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/backend.ts
|
|
372
|
+
var ImageRendererBackend = class {
|
|
373
|
+
surface = null;
|
|
374
|
+
ctx = null;
|
|
375
|
+
/** 图像登记表(核心 setImage 缝):键 = wire src 引用(`qr:` 前缀/远程 URL/任意键) */
|
|
376
|
+
registry = /* @__PURE__ */ new Map();
|
|
377
|
+
/** 解码缓存:键 = src 引用;null = 装配失败标记(静默不画) */
|
|
378
|
+
decoded = /* @__PURE__ */ new Map();
|
|
379
|
+
/** begin 取渲染面;未建面即调用绘制原语属用法错误(模板保证先 begin) */
|
|
380
|
+
canvasCtx() {
|
|
381
|
+
if (this.ctx === null || this.surface === null) {
|
|
382
|
+
throw new Error("\u4F4D\u56FE\u6E32\u67D3\u9762\u672A\u521D\u59CB\u5316:\u5E94\u7ECF\u6E32\u67D3\u6A21\u677F Begin \u5EFA\u9762");
|
|
383
|
+
}
|
|
384
|
+
return this.ctx;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* 渲染前异步预备:解析 src(登记表/data URL/本地路径)并解码出可绘制位图进
|
|
388
|
+
* 缓存。幂等;返回是否可绘制。默认 resolver 物化时逐 src 调用——手工装配
|
|
389
|
+
* 后端时须自行预热,否则 drawImage 静默不画(见文件头「结构性差异」注)
|
|
390
|
+
*/
|
|
391
|
+
async preload(src) {
|
|
392
|
+
if (this.decoded.has(src)) {
|
|
393
|
+
return this.decoded.get(src) !== null;
|
|
394
|
+
}
|
|
395
|
+
const bytes = loadSourceBytes(src, this.registry);
|
|
396
|
+
if (bytes === null) {
|
|
397
|
+
this.decoded.set(src, null);
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
const image = await decodeDrawableImage(bytes);
|
|
401
|
+
if (image === null) {
|
|
402
|
+
this.decoded.set(src, null);
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
this.decoded.set(src, { image, orientation: exifOrientation(bytes) });
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
/** 创建渲染面(width/height ≤0 报错;全新透明画布,对位 image.NewNRGBA) */
|
|
409
|
+
begin(width, height) {
|
|
410
|
+
if (width <= 0 || height <= 0) {
|
|
411
|
+
throw new Error(`\u6E32\u67D3\u9762\u5C3A\u5BF8\u975E\u6CD5: ${width}x${height}`);
|
|
412
|
+
}
|
|
413
|
+
this.surface = createCanvas3(width, height);
|
|
414
|
+
this.ctx = this.surface.getContext("2d");
|
|
415
|
+
}
|
|
416
|
+
/** 收尾:encodeSync('png') 透明底产物;未建面返回 null(对位 go 字面 nil) */
|
|
417
|
+
end() {
|
|
418
|
+
if (this.surface === null) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
return new Uint8Array(this.surface.encodeSync("png"));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* 矩形盒:背景色 + 四边边框。背景为 null 或空串跳过填充(PHP 同款守卫);
|
|
425
|
+
* 边框 = 四边各一条线宽=边框宽的直线(非描边矩形),按盒内嵌绘制,逐边可
|
|
426
|
+
* null/零宽不计,序 top/bottom/left/right(PHP 绘制序,后画覆盖先画;
|
|
427
|
+
* canvas source-over 合成 = go draw.Over 同语义)
|
|
428
|
+
*/
|
|
429
|
+
drawRect(x, y, width, height, bgColor, border) {
|
|
430
|
+
const ctx = this.canvasCtx();
|
|
431
|
+
if (bgColor !== null && bgColor !== "") {
|
|
432
|
+
ctx.fillStyle = cssColor(parseColor(bgColor));
|
|
433
|
+
ctx.fillRect(x, y, width, height);
|
|
434
|
+
}
|
|
435
|
+
const bands = [];
|
|
436
|
+
if (border.top !== null && border.top.width > 0) {
|
|
437
|
+
bands.push({ band: [x, y, width, border.top.width], color: border.top.color });
|
|
438
|
+
}
|
|
439
|
+
if (border.bottom !== null && border.bottom.width > 0) {
|
|
440
|
+
const bandY = Math.max(y, y + height - border.bottom.width);
|
|
441
|
+
bands.push({ band: [x, bandY, width, y + height - bandY], color: border.bottom.color });
|
|
442
|
+
}
|
|
443
|
+
if (border.left !== null && border.left.width > 0) {
|
|
444
|
+
bands.push({ band: [x, y, border.left.width, height], color: border.left.color });
|
|
445
|
+
}
|
|
446
|
+
if (border.right !== null && border.right.width > 0) {
|
|
447
|
+
const bandX = Math.max(x, x + width - border.right.width);
|
|
448
|
+
bands.push({ band: [bandX, y, x + width - bandX, height], color: border.right.color });
|
|
449
|
+
}
|
|
450
|
+
for (const { band, color } of bands) {
|
|
451
|
+
let fill;
|
|
452
|
+
try {
|
|
453
|
+
fill = cssColor(parseColor(color));
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw new Error(`\u89E3\u6790\u8FB9\u6846\u8272: ${error.message}`);
|
|
456
|
+
}
|
|
457
|
+
ctx.fillStyle = fill;
|
|
458
|
+
ctx.fillRect(band[0], band[1], band[2], band[3]);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* 图片双矩形 blit(ADR 0024):把 src 的 srcRect 子矩形拉伸搬运到 dstRect。
|
|
463
|
+
* srcRect 是 raw(内在坐标)空间窗口——napi 位图是 canonical(EXIF 自动转正)
|
|
464
|
+
* 空间,非整图窗口经 canonicalSourceRect 映射进 canonical(04 号决议像素面
|
|
465
|
+
* 口径的落地机制,与 go「先裁后转」逐像素等价);整图哨兵(零宽高)直取
|
|
466
|
+
* canonical 整图(raw 全图转正 = canonical 全图,恒等免映射)。
|
|
467
|
+
* dstRect 尺寸 ≤0 防护先于读取跳过;src 未装配/装配失败/映射窗与位图不相交
|
|
468
|
+
* 一律静默不画(缺省约定,见文件头)
|
|
469
|
+
*/
|
|
470
|
+
drawImage(src, srcRect, dstRect) {
|
|
471
|
+
const ctx = this.canvasCtx();
|
|
472
|
+
if (dstRect.width <= 0 || dstRect.height <= 0) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const entry = this.decoded.get(src);
|
|
476
|
+
if (entry === void 0 || entry === null) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const { image, orientation } = entry;
|
|
480
|
+
let window;
|
|
481
|
+
if (srcRect.width <= 0 || srcRect.height <= 0) {
|
|
482
|
+
window = { x: 0, y: 0, width: image.width, height: image.height };
|
|
483
|
+
} else {
|
|
484
|
+
const raw = rawDimensions(image.width, image.height, orientation);
|
|
485
|
+
window = canonicalSourceRect(srcRect, orientation, raw.width, raw.height);
|
|
486
|
+
}
|
|
487
|
+
const x0 = Math.max(0, Math.ceil(window.x));
|
|
488
|
+
const y0 = Math.max(0, Math.ceil(window.y));
|
|
489
|
+
const x1 = Math.min(image.width, window.x + window.width);
|
|
490
|
+
const y1 = Math.min(image.height, window.y + window.height);
|
|
491
|
+
if (x1 <= x0 || y1 <= y0) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
ctx.drawImage(image, x0, y0, x1 - x0, y1 - y0, dstRect.x, dstRect.y, dstRect.width, dstRect.height);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* 单行文本(对位 go text.go DrawText):原语层永远真实字体绘制——字体装配/
|
|
498
|
+
* TTC 拒收/内置默认字体判定/对齐锚点与基线差/角度旋转见 text.ts
|
|
499
|
+
* (drawTextLine)。空行零副作用;字色解析失败中止
|
|
500
|
+
*/
|
|
501
|
+
drawText(line, x, y, options) {
|
|
502
|
+
const ctx = this.canvasCtx();
|
|
503
|
+
drawTextLine(ctx, line, x, y, options);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* 源图内在尺寸度量(ADR 0023/0024 查询口):返 raw(未转正)尺寸——对位
|
|
507
|
+
* go image.DecodeConfig / PHP getimagesize 的内在坐标系。查不到/解码失败返
|
|
508
|
+
* null → 核心统一退化 fill 形态。
|
|
509
|
+
* 两路在「raw 出参」契约上收敛(napi 同步/异步口径差异的实测收敛,见
|
|
510
|
+
* decode.ts decodeHeaderSize docblock):
|
|
511
|
+
* - preload 缓存命中:位图是 canonical(异步解码已转正),按 DecodedEntry
|
|
512
|
+
* 已存的 orientation ≥5 互换换回 raw(评审 P0-3:缓存臂不重读磁盘重算
|
|
513
|
+
* orientation——字节不可得时兜底 1 会产出 canonical 错误空间尺寸;不产出
|
|
514
|
+
* 空间错误的尺寸,弃用既有已存数据更是错上加错);
|
|
515
|
+
* - 未装配 src:同步头部探测直出 raw 头尺寸(同步口不施加转正),原样返回
|
|
516
|
+
*/
|
|
517
|
+
imageIntrinsicSize(src) {
|
|
518
|
+
const cached = this.decoded.get(src);
|
|
519
|
+
if (cached !== void 0 && cached !== null) {
|
|
520
|
+
return rawDimensions(cached.image.width, cached.image.height, cached.orientation);
|
|
521
|
+
}
|
|
522
|
+
const bytes = loadSourceBytes(src, this.registry);
|
|
523
|
+
if (bytes === null) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
return decodeHeaderSize(bytes);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 图像登记缝(核心 setImage 契约):物化器按键登记资源字节,键即 drawImage
|
|
530
|
+
* 收到的 src 引用。字节拷贝入表(所有权归登记表,防外部原地改写);同键重
|
|
531
|
+
* 登记使解码缓存失效(下次 preload 重装配)
|
|
532
|
+
*/
|
|
533
|
+
setImage(key, data) {
|
|
534
|
+
this.registry.set(key, new Uint8Array(data));
|
|
535
|
+
this.decoded.delete(key);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* 字体物化缝(resolver 的远程字体下载字节按键登记;核心 ResourceResolver
|
|
539
|
+
* 契约「注册缝归后端包契约」的本包落点):TTC 拒收与 GlobalFonts 注册在
|
|
540
|
+
* 此处完成(物化阶段 fail-fast,渲染不带病启动),drawText 以 wire font
|
|
541
|
+
* 引用(URL 原文)直取。幂等(同字节同别名)
|
|
542
|
+
*/
|
|
543
|
+
registerFont(ref, data) {
|
|
544
|
+
registerFontData(ref, data);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// src/resolver.ts
|
|
549
|
+
import { createHash as createHash2 } from "crypto";
|
|
550
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
551
|
+
import { join as join2 } from "path";
|
|
552
|
+
import {
|
|
553
|
+
qrImageSrc,
|
|
554
|
+
ResourceMaterializeError as ResourceMaterializeError2,
|
|
555
|
+
layerWidth
|
|
556
|
+
} from "@hankchen/canvas";
|
|
557
|
+
|
|
558
|
+
// src/qr.ts
|
|
559
|
+
import QRCode from "qrcode";
|
|
560
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
561
|
+
import { ResourceMaterializeError } from "@hankchen/canvas";
|
|
562
|
+
var QrCodeMaterializer = class {
|
|
563
|
+
async materialize(text, width) {
|
|
564
|
+
const size = Math.max(width, 1);
|
|
565
|
+
const segment = segmentFor(text, pickEncMode(text));
|
|
566
|
+
try {
|
|
567
|
+
const code = QRCode.create([segment], { errorCorrectionLevel: "H" });
|
|
568
|
+
const blockWidth = Math.min(Math.max(Math.trunc(size / code.modules.size), 1), 255);
|
|
569
|
+
const png = await QRCode.toBuffer([segment], {
|
|
570
|
+
errorCorrectionLevel: "H",
|
|
571
|
+
margin: 0,
|
|
572
|
+
scale: blockWidth,
|
|
573
|
+
color: { dark: "#000000ff", light: "#ffffffff" }
|
|
574
|
+
});
|
|
575
|
+
return new Uint8Array(png);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
throw new ResourceMaterializeError("qr_generate_failed", `\u4E8C\u7EF4\u7801\u751F\u6210\u5931\u8D25(${text}): ${error.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
function pickEncMode(text) {
|
|
582
|
+
if (text === "") {
|
|
583
|
+
return "byte";
|
|
584
|
+
}
|
|
585
|
+
if (isAllDigits(text)) {
|
|
586
|
+
return "numeric";
|
|
587
|
+
}
|
|
588
|
+
if (isAllAlphaNum(text)) {
|
|
589
|
+
return "alphanumeric";
|
|
590
|
+
}
|
|
591
|
+
return "byte";
|
|
592
|
+
}
|
|
593
|
+
function segmentFor(text, mode) {
|
|
594
|
+
switch (mode) {
|
|
595
|
+
case "numeric":
|
|
596
|
+
return { data: text, mode: "numeric" };
|
|
597
|
+
case "alphanumeric":
|
|
598
|
+
return { data: text, mode: "alphanumeric" };
|
|
599
|
+
default:
|
|
600
|
+
return { data: Buffer2.from(text, "utf8"), mode: "byte" };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function isAllDigits(text) {
|
|
604
|
+
for (const ch of text) {
|
|
605
|
+
if (ch < "0" || ch > "9") {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
function isAllAlphaNum(text) {
|
|
612
|
+
for (const ch of text) {
|
|
613
|
+
if ((ch < "0" || ch > "9") && (ch < "A" || ch > "Z")) {
|
|
614
|
+
switch (ch) {
|
|
615
|
+
case " ":
|
|
616
|
+
case "$":
|
|
617
|
+
case "%":
|
|
618
|
+
case "*":
|
|
619
|
+
case "+":
|
|
620
|
+
case "-":
|
|
621
|
+
case ".":
|
|
622
|
+
case "/":
|
|
623
|
+
case ":":
|
|
624
|
+
break;
|
|
625
|
+
default:
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/resolver.ts
|
|
634
|
+
var SUB_IMAGES = "img_layers";
|
|
635
|
+
var SUB_FONTS = "text_layers";
|
|
636
|
+
var SUB_QR = "qr_layers";
|
|
637
|
+
function createDefaultResolver(options = {}) {
|
|
638
|
+
const fetchLike = options.fetchLike ?? ((url) => globalThis.fetch(url));
|
|
639
|
+
const qr = options.qrMaterializer === void 0 ? new QrCodeMaterializer() : options.qrMaterializer;
|
|
640
|
+
const memory = /* @__PURE__ */ new Map();
|
|
641
|
+
const cacheRoot = options.cacheRoot;
|
|
642
|
+
async function cachedRemoteBytes(sub, rawUrl) {
|
|
643
|
+
const key = `${sub}:${cacheKey(rawUrl)}`;
|
|
644
|
+
const hit = memory.get(key);
|
|
645
|
+
if (hit !== void 0) {
|
|
646
|
+
return hit;
|
|
647
|
+
}
|
|
648
|
+
const file = cacheRoot === void 0 ? null : join2(cacheRoot, sub, cacheKey(rawUrl));
|
|
649
|
+
if (file !== null) {
|
|
650
|
+
try {
|
|
651
|
+
const bytes = new Uint8Array(readFileSync3(file));
|
|
652
|
+
memory.set(key, bytes);
|
|
653
|
+
return bytes;
|
|
654
|
+
} catch {
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const content = await download(rawUrl, fetchLike);
|
|
658
|
+
if (file !== null) {
|
|
659
|
+
try {
|
|
660
|
+
mkdirSync2(join2(cacheRoot, sub), { recursive: true });
|
|
661
|
+
writeFileSync2(file, content);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
throw new ResourceMaterializeError2(
|
|
664
|
+
"resource_save_failed",
|
|
665
|
+
`remote file(${rawUrl}) save to cache path failed: ${error.message}`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
memory.set(key, content);
|
|
670
|
+
return content;
|
|
671
|
+
}
|
|
672
|
+
async function cachedQrBytes(text, width) {
|
|
673
|
+
const key = `qr:${qrCacheKey(text, width)}`;
|
|
674
|
+
const hit = memory.get(key);
|
|
675
|
+
if (hit !== void 0) {
|
|
676
|
+
return hit;
|
|
677
|
+
}
|
|
678
|
+
const file = cacheRoot === void 0 ? null : join2(cacheRoot, SUB_QR, qrCacheKey(text, width));
|
|
679
|
+
if (file !== null) {
|
|
680
|
+
try {
|
|
681
|
+
const bytes2 = new Uint8Array(readFileSync3(file));
|
|
682
|
+
memory.set(key, bytes2);
|
|
683
|
+
return bytes2;
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const bytes = await qr.materialize(text, width);
|
|
688
|
+
if (file !== null) {
|
|
689
|
+
try {
|
|
690
|
+
mkdirSync2(join2(cacheRoot, SUB_QR), { recursive: true });
|
|
691
|
+
writeFileSync2(file, bytes);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
throw new ResourceMaterializeError2("qr_generate_failed", `qr code save to cache path failed: ${error.message}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
memory.set(key, bytes);
|
|
697
|
+
return bytes;
|
|
698
|
+
}
|
|
699
|
+
async function registerImage(backend, key, bytes) {
|
|
700
|
+
if (typeof backend.setImage !== "function") {
|
|
701
|
+
throw new Error("\u8D44\u6E90\u7269\u5316\u9700\u8981\u540E\u7AEF\u5B9E\u73B0 setImage \u767B\u8BB0\u7F1D\uFF08ImageRendererBackend \u5373\u5951\u5408\uFF09");
|
|
702
|
+
}
|
|
703
|
+
backend.setImage(key, bytes);
|
|
704
|
+
await preloadQuietly(backend, key);
|
|
705
|
+
}
|
|
706
|
+
async function preloadQuietly(backend, src) {
|
|
707
|
+
if (typeof backend.preload === "function") {
|
|
708
|
+
await backend.preload(src);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async function resolve(canvas, backend) {
|
|
712
|
+
const materialization = backend;
|
|
713
|
+
if (canvas.background !== null && canvas.background.image !== null) {
|
|
714
|
+
const image = canvas.background.image;
|
|
715
|
+
if (isRemoteURL(image)) {
|
|
716
|
+
await registerImage(materialization, image, await cachedRemoteBytes(SUB_IMAGES, image));
|
|
717
|
+
} else {
|
|
718
|
+
await preloadQuietly(materialization, image);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const layer of canvas.layers) {
|
|
722
|
+
await resolveLayer(layer, materialization);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function resolveLayer(layer, backend) {
|
|
726
|
+
switch (layer.type) {
|
|
727
|
+
case "TableLayer":
|
|
728
|
+
for (const row of layer.rows) {
|
|
729
|
+
await resolveLayer(row, backend);
|
|
730
|
+
}
|
|
731
|
+
break;
|
|
732
|
+
case "TableRowLayer":
|
|
733
|
+
for (const cell of layer.cells) {
|
|
734
|
+
await resolveLayer(cell, backend);
|
|
735
|
+
}
|
|
736
|
+
break;
|
|
737
|
+
case "TableCellLayer":
|
|
738
|
+
if (layer.content !== null) {
|
|
739
|
+
await resolveLayer(layer.content, backend);
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
case "ImageLayer": {
|
|
743
|
+
const src = layer.src;
|
|
744
|
+
if (src === null) {
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
if (isRemoteURL(src)) {
|
|
748
|
+
await registerImage(backend, src, await cachedRemoteBytes(SUB_IMAGES, src));
|
|
749
|
+
} else {
|
|
750
|
+
await preloadQuietly(backend, src);
|
|
751
|
+
}
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
case "TextLayer": {
|
|
755
|
+
const font = layer.font;
|
|
756
|
+
if (font === "" || isNumeric(font) || !isRemoteURL(font)) {
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
const bytes = await cachedRemoteBytes(SUB_FONTS, font);
|
|
760
|
+
if (typeof backend.registerFont !== "function") {
|
|
761
|
+
throw new Error("\u8FDC\u7A0B\u5B57\u4F53\u7269\u5316\u9700\u8981\u540E\u7AEF\u5B9E\u73B0 registerFont \u7F1D\uFF08ImageRendererBackend \u5373\u5951\u5408\uFF09");
|
|
762
|
+
}
|
|
763
|
+
backend.registerFont(font, bytes);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case "QrCodeLayer": {
|
|
767
|
+
const value = layer.value;
|
|
768
|
+
if (value === "") {
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
if (qr === null) {
|
|
772
|
+
throw new ResourceMaterializeError2(
|
|
773
|
+
"qr_materializer_required",
|
|
774
|
+
"\u4E8C\u7EF4\u7801\u56FE\u5C42\u7269\u5316\u9700\u8981\u6CE8\u5165 QRMaterializer\uFF08ADR-0002\uFF1A\u5B9E\u73B0\u5728\u6E32\u67D3\u540E\u7AEF\u5305\uFF09"
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
await registerImage(backend, qrImageSrc(value), await cachedQrBytes(value, layerWidth(layer)));
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return { resolve };
|
|
783
|
+
}
|
|
784
|
+
async function download(rawUrl, fetchLike) {
|
|
785
|
+
let response;
|
|
786
|
+
try {
|
|
787
|
+
response = await fetchLike(rawUrl);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
throw new ResourceMaterializeError2("resource_download_failed", `could not get remote file(${rawUrl}): ${error.message}`);
|
|
790
|
+
}
|
|
791
|
+
if (!response.ok) {
|
|
792
|
+
throw new ResourceMaterializeError2("resource_download_failed", `could not get remote file(${rawUrl}): HTTP ${response.status}`);
|
|
793
|
+
}
|
|
794
|
+
const buffer = await response.arrayBuffer();
|
|
795
|
+
if (buffer.byteLength === 0) {
|
|
796
|
+
throw new ResourceMaterializeError2("resource_download_failed", `could not get remote file(${rawUrl}): \u7A7A\u54CD\u5E94\u4F53`);
|
|
797
|
+
}
|
|
798
|
+
return new Uint8Array(buffer);
|
|
799
|
+
}
|
|
800
|
+
function cacheKey(rawUrl) {
|
|
801
|
+
const sum = createHash2("sha256").update(rawUrl).digest("hex");
|
|
802
|
+
let ext = "";
|
|
803
|
+
try {
|
|
804
|
+
const path = new URL(rawUrl).pathname;
|
|
805
|
+
ext = path.match(/(\.[a-zA-Z0-9]+)$/)?.[1] ?? "";
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
return sum + ext;
|
|
809
|
+
}
|
|
810
|
+
function qrCacheKey(text, width) {
|
|
811
|
+
return createHash2("sha256").update(`${text}_${width}`).digest("hex") + ".png";
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// src/index.ts
|
|
815
|
+
var PACKAGE_NAME = "@hankchen/canvas-image-renderer";
|
|
816
|
+
export {
|
|
817
|
+
ImageRendererBackend,
|
|
818
|
+
PACKAGE_NAME,
|
|
819
|
+
QrCodeMaterializer,
|
|
820
|
+
canonicalSourceRect,
|
|
821
|
+
createDefaultResolver,
|
|
822
|
+
createNapiMeasurerFactory,
|
|
823
|
+
cssColor,
|
|
824
|
+
decodeDrawableImage,
|
|
825
|
+
decodeHeaderSize,
|
|
826
|
+
drawTextLine,
|
|
827
|
+
exifOrientation,
|
|
828
|
+
isBuiltinFont,
|
|
829
|
+
loadFontFamily,
|
|
830
|
+
loadSourceBytes,
|
|
831
|
+
parseColor,
|
|
832
|
+
rawDimensions,
|
|
833
|
+
registerFontData
|
|
834
|
+
};
|
|
835
|
+
//# sourceMappingURL=index.js.map
|