satoru-render 1.0.11 → 1.0.13
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 +113 -0
- package/README.md +76 -9
- package/dist/cli.js +60 -0
- package/dist/core.d.ts +105 -0
- package/dist/core.js +227 -33
- package/dist/resources.d.ts +60 -0
- package/dist/resources.js +175 -0
- package/dist/satoru-single.js +0 -0
- package/dist/satoru.js +2 -2
- package/dist/satoru.wasm +0 -0
- package/dist/web-workers.js +473 -247
- package/dist/workers-parent.js +316 -37
- package/dist/workers.d.ts +39 -4
- package/dist/workers.js +104 -5
- package/package.json +15 -7
package/dist/core.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { LogLevel } from "./log-level.js";
|
|
2
|
+
export const DIAGNOSTIC_CODES = {
|
|
3
|
+
LIMIT_TIMEOUT: "LIMIT_TIMEOUT",
|
|
4
|
+
LIMIT_RESOURCE_SIZE: "LIMIT_RESOURCE_SIZE",
|
|
5
|
+
LIMIT_TOTAL_SIZE: "LIMIT_TOTAL_SIZE",
|
|
6
|
+
LIMIT_RESOURCE_COUNT: "LIMIT_RESOURCE_COUNT",
|
|
7
|
+
LIMIT_PROTOCOL_BLOCKED: "LIMIT_PROTOCOL_BLOCKED",
|
|
8
|
+
LIMIT_HOST_BLOCKED: "LIMIT_HOST_BLOCKED",
|
|
9
|
+
};
|
|
2
10
|
const emojiUrl = "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2";
|
|
3
11
|
export const DEFAULT_FONT_MAP = {
|
|
4
12
|
"sans-serif": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP",
|
|
@@ -312,40 +320,32 @@ export class SatoruBase {
|
|
|
312
320
|
}
|
|
313
321
|
async render(options) {
|
|
314
322
|
let { format = "svg", value, url, baseUrl } = options;
|
|
315
|
-
const profileEnabled = options.profile === true;
|
|
323
|
+
const profileEnabled = options.profile === true || options.diagnostics === true;
|
|
316
324
|
const profile = {};
|
|
317
325
|
const now = () => typeof performance !== "undefined" && performance.now
|
|
318
326
|
? performance.now()
|
|
319
327
|
: Date.now();
|
|
328
|
+
const startTime = now();
|
|
329
|
+
const limits = options.limits ?? {};
|
|
330
|
+
let totalResourceBytes = 0;
|
|
331
|
+
let resourceCount = 0;
|
|
320
332
|
const addProfile = (name, elapsed) => {
|
|
321
333
|
if (!profileEnabled)
|
|
322
334
|
return;
|
|
323
335
|
profile[name] = (profile[name] ?? 0) + elapsed;
|
|
324
336
|
};
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const mod = await this.getModule();
|
|
338
|
-
const instancePtr = mod.create_instance();
|
|
339
|
-
try {
|
|
340
|
-
const result = mod.merge_pdfs(instancePtr, pagePdfs);
|
|
341
|
-
if (!result)
|
|
342
|
-
return new Uint8Array();
|
|
343
|
-
return new Uint8Array(result);
|
|
344
|
-
}
|
|
345
|
-
finally {
|
|
346
|
-
mod.destroy_instance(instancePtr);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
337
|
+
const diagnosticsReport = options.diagnostics ? {
|
|
338
|
+
version: 1,
|
|
339
|
+
format: format,
|
|
340
|
+
width: options.width,
|
|
341
|
+
height: options.height,
|
|
342
|
+
mediaType: options.mediaType ?? "screen",
|
|
343
|
+
timings: profile,
|
|
344
|
+
resources: [],
|
|
345
|
+
fonts: [],
|
|
346
|
+
warnings: [],
|
|
347
|
+
errors: [],
|
|
348
|
+
} : null;
|
|
349
349
|
let mod = await this.getModule();
|
|
350
350
|
const { width, height = 0, fonts, images, css, logLevel, onLog } = options;
|
|
351
351
|
if (!options.userAgent) {
|
|
@@ -370,6 +370,7 @@ export class SatoruBase {
|
|
|
370
370
|
this.currentFontMap = options.fontMap ?? DEFAULT_FONT_MAP;
|
|
371
371
|
const instancePtr = mod.create_instance();
|
|
372
372
|
mod.set_font_map(instancePtr, this.currentFontMap);
|
|
373
|
+
mod.set_collect_profile_enabled(instancePtr, profileEnabled);
|
|
373
374
|
try {
|
|
374
375
|
const defaultResolver = (r) => this.resolveDefaultResource(r, baseUrl, options.userAgent);
|
|
375
376
|
const resolver = options.resolveResource
|
|
@@ -380,12 +381,107 @@ export class SatoruBase {
|
|
|
380
381
|
const cachedResolver = async (r) => {
|
|
381
382
|
const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
|
|
382
383
|
const cached = this.resourceCache.get(cacheKey);
|
|
384
|
+
let resourceDiag;
|
|
385
|
+
if (diagnosticsReport) {
|
|
386
|
+
resourceDiag = {
|
|
387
|
+
type: r.type,
|
|
388
|
+
url: r.url,
|
|
389
|
+
name: r.name,
|
|
390
|
+
status: "pending",
|
|
391
|
+
};
|
|
392
|
+
diagnosticsReport.resources.push(resourceDiag);
|
|
393
|
+
}
|
|
383
394
|
if (cached) {
|
|
384
395
|
addProfile("resourceCacheHitsCount", 1);
|
|
396
|
+
if (resourceDiag) {
|
|
397
|
+
resourceDiag.status = "loaded";
|
|
398
|
+
if (cached instanceof Uint8Array) {
|
|
399
|
+
resourceDiag.bytes = cached.byteLength;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
385
402
|
return cached;
|
|
386
403
|
}
|
|
404
|
+
// Apply Limits
|
|
405
|
+
if (limits.maxResourceCount && resourceCount >= limits.maxResourceCount) {
|
|
406
|
+
const msg = `Maximum resource count (${limits.maxResourceCount}) exceeded`;
|
|
407
|
+
if (resourceDiag) {
|
|
408
|
+
resourceDiag.status = "skipped";
|
|
409
|
+
resourceDiag.reason = msg;
|
|
410
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_RESOURCE_COUNT, message: msg, source: r.url });
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
if (limits.allowedProtocols || limits.allowedHosts || limits.blockedHosts) {
|
|
415
|
+
try {
|
|
416
|
+
const urlObj = new URL(r.url);
|
|
417
|
+
if (limits.allowedProtocols && !limits.allowedProtocols.includes(urlObj.protocol)) {
|
|
418
|
+
const msg = `Protocol ${urlObj.protocol} is blocked`;
|
|
419
|
+
if (resourceDiag) {
|
|
420
|
+
resourceDiag.status = "skipped";
|
|
421
|
+
resourceDiag.reason = msg;
|
|
422
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_PROTOCOL_BLOCKED, message: msg, source: r.url });
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if (limits.allowedHosts && !limits.allowedHosts.includes(urlObj.hostname)) {
|
|
427
|
+
const msg = `Host ${urlObj.hostname} is not in allowed list`;
|
|
428
|
+
if (resourceDiag) {
|
|
429
|
+
resourceDiag.status = "skipped";
|
|
430
|
+
resourceDiag.reason = msg;
|
|
431
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED, message: msg, source: r.url });
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
if (limits.blockedHosts && limits.blockedHosts.includes(urlObj.hostname)) {
|
|
436
|
+
const msg = `Host ${urlObj.hostname} is blocked`;
|
|
437
|
+
if (resourceDiag) {
|
|
438
|
+
resourceDiag.status = "skipped";
|
|
439
|
+
resourceDiag.reason = msg;
|
|
440
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED, message: msg, source: r.url });
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
// Invalid URL - skip protocol/host checks for local/relative paths
|
|
447
|
+
}
|
|
448
|
+
}
|
|
387
449
|
const resolveStart = now();
|
|
388
|
-
|
|
450
|
+
let result = null;
|
|
451
|
+
try {
|
|
452
|
+
result = await resolver(r);
|
|
453
|
+
if (resourceDiag) {
|
|
454
|
+
resourceDiag.status = result ? "loaded" : "failed";
|
|
455
|
+
if (result) {
|
|
456
|
+
const bytes = result instanceof Uint8Array ? result.byteLength :
|
|
457
|
+
result.buffer ? result.byteLength :
|
|
458
|
+
("css" in result) ? result.css.byteLength + result.fonts.reduce((acc, f) => acc + f.data.byteLength, 0) : 0;
|
|
459
|
+
resourceDiag.bytes = bytes;
|
|
460
|
+
if (limits.maxResourceBytes && bytes > limits.maxResourceBytes) {
|
|
461
|
+
const msg = `Resource size (${bytes} bytes) exceeds limit (${limits.maxResourceBytes})`;
|
|
462
|
+
resourceDiag.status = "skipped";
|
|
463
|
+
resourceDiag.reason = msg;
|
|
464
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_RESOURCE_SIZE, message: msg, source: r.url });
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
if (limits.maxTotalResourceBytes && totalResourceBytes + bytes > limits.maxTotalResourceBytes) {
|
|
468
|
+
const msg = `Total resource size exceeds limit (${limits.maxTotalResourceBytes})`;
|
|
469
|
+
resourceDiag.status = "skipped";
|
|
470
|
+
resourceDiag.reason = msg;
|
|
471
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TOTAL_SIZE, message: msg, source: r.url });
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
totalResourceBytes += bytes;
|
|
475
|
+
resourceCount++;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
if (resourceDiag) {
|
|
481
|
+
resourceDiag.status = "failed";
|
|
482
|
+
resourceDiag.reason = e.message || String(e);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
389
485
|
addProfile("resolveResources", now() - resolveStart);
|
|
390
486
|
if (result) {
|
|
391
487
|
if (result instanceof Uint8Array) {
|
|
@@ -441,14 +537,36 @@ export class SatoruBase {
|
|
|
441
537
|
};
|
|
442
538
|
const inputHtmls = Array.isArray(value) ? value : [value];
|
|
443
539
|
const processedHtmls = [];
|
|
540
|
+
const utf8Decoder = new TextDecoder();
|
|
541
|
+
const utf8Encoder = new TextEncoder();
|
|
444
542
|
const resolvedResources = new Set();
|
|
445
543
|
for (const rawHtml of inputHtmls) {
|
|
446
544
|
let processedHtml = rawHtml;
|
|
447
545
|
for (let i = 0; i < 10; i++) {
|
|
546
|
+
if (options.signal?.aborted) {
|
|
547
|
+
throw new Error("Render aborted");
|
|
548
|
+
}
|
|
549
|
+
if (typeof limits.timeoutMs !== "undefined" && now() - startTime >= limits.timeoutMs) {
|
|
550
|
+
const msg = `Render timed out after ${limits.timeoutMs}ms`;
|
|
551
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT, message: msg });
|
|
552
|
+
throw new Error(msg);
|
|
553
|
+
}
|
|
448
554
|
addProfile("collectResourcesCount", 1);
|
|
449
555
|
const collectStart = now();
|
|
450
556
|
mod.collect_resources(instancePtr, processedHtml, width, height, options.mediaType === "print" ? 1 : 0);
|
|
451
|
-
|
|
557
|
+
const collectElapsed = now() - collectStart;
|
|
558
|
+
addProfile("collectResources", collectElapsed);
|
|
559
|
+
addProfile(`collectResourcesRound${i + 1}`, collectElapsed);
|
|
560
|
+
if (profileEnabled) {
|
|
561
|
+
try {
|
|
562
|
+
const collectProfile = JSON.parse(mod.get_collect_profile(instancePtr));
|
|
563
|
+
for (const [key, value] of Object.entries(collectProfile)) {
|
|
564
|
+
addProfile(key, value);
|
|
565
|
+
addProfile(`${key}Round${i + 1}`, value);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch { }
|
|
569
|
+
}
|
|
452
570
|
const pendingStart = now();
|
|
453
571
|
const binary = mod.get_pending_resources(instancePtr);
|
|
454
572
|
addProfile("getPendingResources", now() - pendingStart);
|
|
@@ -465,15 +583,15 @@ export class SatoruBase {
|
|
|
465
583
|
const redraw_on_ready = view.getUint8(offset++) !== 0;
|
|
466
584
|
const urlLen = view.getUint32(offset, true);
|
|
467
585
|
offset += 4;
|
|
468
|
-
const url =
|
|
586
|
+
const url = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
|
|
469
587
|
offset += urlLen;
|
|
470
588
|
const nameLen = view.getUint32(offset, true);
|
|
471
589
|
offset += 4;
|
|
472
|
-
const name =
|
|
590
|
+
const name = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
|
|
473
591
|
offset += nameLen;
|
|
474
592
|
const charsLen = view.getUint32(offset, true);
|
|
475
593
|
offset += 4;
|
|
476
|
-
const characters =
|
|
594
|
+
const characters = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
|
|
477
595
|
offset += charsLen;
|
|
478
596
|
let type = "font";
|
|
479
597
|
if (typeInt === 2)
|
|
@@ -484,11 +602,31 @@ export class SatoruBase {
|
|
|
484
602
|
}
|
|
485
603
|
const pending = resources.filter((r) => {
|
|
486
604
|
const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
|
|
605
|
+
if (r.type === "font" && resolvedResources.has(`font:${r.url}:`)) {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
487
608
|
return !resolvedResources.has(key);
|
|
488
609
|
});
|
|
610
|
+
addProfile("pendingResourcesCount", pending.length);
|
|
611
|
+
addProfile(`pendingResourcesRound${i + 1}Count`, pending.length);
|
|
612
|
+
for (const r of pending) {
|
|
613
|
+
if (r.type === "font") {
|
|
614
|
+
addProfile("pendingFontResourcesCount", 1);
|
|
615
|
+
addProfile(`pendingFontResourcesRound${i + 1}Count`, 1);
|
|
616
|
+
}
|
|
617
|
+
else if (r.type === "css") {
|
|
618
|
+
addProfile("pendingCssResourcesCount", 1);
|
|
619
|
+
addProfile(`pendingCssResourcesRound${i + 1}Count`, 1);
|
|
620
|
+
}
|
|
621
|
+
else if (r.type === "image") {
|
|
622
|
+
addProfile("pendingImageResourcesCount", 1);
|
|
623
|
+
addProfile(`pendingImageResourcesRound${i + 1}Count`, 1);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
489
626
|
addProfile("parsePendingResources", now() - parseStart);
|
|
490
627
|
if (pending.length === 0)
|
|
491
628
|
break;
|
|
629
|
+
addProfile("pendingResourceRoundsCount", 1);
|
|
492
630
|
const loadStart = now();
|
|
493
631
|
await Promise.all(pending.map(async (r) => {
|
|
494
632
|
try {
|
|
@@ -497,6 +635,9 @@ export class SatoruBase {
|
|
|
497
635
|
}
|
|
498
636
|
const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
|
|
499
637
|
resolvedResources.add(key);
|
|
638
|
+
if (r.type === "font") {
|
|
639
|
+
resolvedResources.add(`font:${r.url}:`);
|
|
640
|
+
}
|
|
500
641
|
const data = await cachedResolver({ ...r });
|
|
501
642
|
if (!data)
|
|
502
643
|
return;
|
|
@@ -523,7 +664,7 @@ export class SatoruBase {
|
|
|
523
664
|
? data
|
|
524
665
|
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
525
666
|
if (r.type === "css") {
|
|
526
|
-
const cssText =
|
|
667
|
+
const cssText = utf8Decoder.decode(finalUint8);
|
|
527
668
|
const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(r.url) ||
|
|
528
669
|
r.url.startsWith("data:");
|
|
529
670
|
let cssBaseUrl = r.url;
|
|
@@ -551,7 +692,7 @@ export class SatoruBase {
|
|
|
551
692
|
return match;
|
|
552
693
|
}
|
|
553
694
|
});
|
|
554
|
-
finalUint8 =
|
|
695
|
+
finalUint8 = utf8Encoder.encode(rewrittenCss);
|
|
555
696
|
}
|
|
556
697
|
}
|
|
557
698
|
await loadResourceData(r, finalUint8);
|
|
@@ -586,6 +727,14 @@ export class SatoruBase {
|
|
|
586
727
|
pdf: 3,
|
|
587
728
|
};
|
|
588
729
|
const renderStart = now();
|
|
730
|
+
if (options.signal?.aborted) {
|
|
731
|
+
throw new Error("Render aborted");
|
|
732
|
+
}
|
|
733
|
+
if (typeof limits.timeoutMs !== "undefined" && now() - startTime >= limits.timeoutMs) {
|
|
734
|
+
const msg = `Render timed out after ${limits.timeoutMs}ms`;
|
|
735
|
+
diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT, message: msg });
|
|
736
|
+
throw new Error(msg);
|
|
737
|
+
}
|
|
589
738
|
const result = (processedHtmls.length === 1)
|
|
590
739
|
? mod.render_from_state(instancePtr, width, height, formatMap[format] ?? 0, {
|
|
591
740
|
svgTextToPaths: options.textToPaths ?? true,
|
|
@@ -600,6 +749,18 @@ export class SatoruBase {
|
|
|
600
749
|
fitPositionY: options.fitPosition?.y ?? 0.5,
|
|
601
750
|
backgroundColor: this.parseColor(options.backgroundColor),
|
|
602
751
|
mediaType: options.mediaType === "print" ? 1 : 0,
|
|
752
|
+
pdfTitle: options.pdfTitle ?? "",
|
|
753
|
+
pdfAuthor: options.pdfAuthor ?? "",
|
|
754
|
+
pdfSubject: options.pdfSubject ?? "",
|
|
755
|
+
pdfKeywords: options.pdfKeywords ?? "",
|
|
756
|
+
pdfCreator: options.pdfCreator ?? "",
|
|
757
|
+
pdfProducer: options.pdfProducer ?? "",
|
|
758
|
+
pdfMarginTop: options.pdfMargin?.top ?? 0,
|
|
759
|
+
pdfMarginRight: options.pdfMargin?.right ?? 0,
|
|
760
|
+
pdfMarginBottom: options.pdfMargin?.bottom ?? 0,
|
|
761
|
+
pdfMarginLeft: options.pdfMargin?.left ?? 0,
|
|
762
|
+
pdfHeader: options.pdfHeader ?? "",
|
|
763
|
+
pdfFooter: options.pdfFooter ?? "",
|
|
603
764
|
})
|
|
604
765
|
: mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
|
|
605
766
|
svgTextToPaths: options.textToPaths ?? true,
|
|
@@ -614,25 +775,58 @@ export class SatoruBase {
|
|
|
614
775
|
fitPositionY: options.fitPosition?.y ?? 0.5,
|
|
615
776
|
backgroundColor: this.parseColor(options.backgroundColor),
|
|
616
777
|
mediaType: options.mediaType === "print" ? 1 : 0,
|
|
778
|
+
pdfTitle: options.pdfTitle ?? "",
|
|
779
|
+
pdfAuthor: options.pdfAuthor ?? "",
|
|
780
|
+
pdfSubject: options.pdfSubject ?? "",
|
|
781
|
+
pdfKeywords: options.pdfKeywords ?? "",
|
|
782
|
+
pdfCreator: options.pdfCreator ?? "",
|
|
783
|
+
pdfProducer: options.pdfProducer ?? "",
|
|
784
|
+
pdfMarginTop: options.pdfMargin?.top ?? 0,
|
|
785
|
+
pdfMarginRight: options.pdfMargin?.right ?? 0,
|
|
786
|
+
pdfMarginBottom: options.pdfMargin?.bottom ?? 0,
|
|
787
|
+
pdfMarginLeft: options.pdfMargin?.left ?? 0,
|
|
788
|
+
pdfHeader: options.pdfHeader ?? "",
|
|
789
|
+
pdfFooter: options.pdfFooter ?? "",
|
|
617
790
|
});
|
|
618
791
|
addProfile("wasmRender", now() - renderStart);
|
|
619
792
|
if (!result) {
|
|
620
793
|
options.onProfile?.(profile);
|
|
794
|
+
if (diagnosticsReport) {
|
|
795
|
+
try {
|
|
796
|
+
diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
|
|
797
|
+
}
|
|
798
|
+
catch { }
|
|
799
|
+
options.onDiagnostics?.(diagnosticsReport);
|
|
800
|
+
}
|
|
621
801
|
if (format === "svg")
|
|
622
802
|
return "";
|
|
623
803
|
return new Uint8Array();
|
|
624
804
|
}
|
|
625
805
|
if (format === "svg") {
|
|
626
806
|
const decodeStart = now();
|
|
627
|
-
const svg =
|
|
807
|
+
const svg = utf8Decoder.decode(result);
|
|
628
808
|
addProfile("decodeResult", now() - decodeStart);
|
|
629
809
|
options.onProfile?.(profile);
|
|
810
|
+
if (diagnosticsReport) {
|
|
811
|
+
try {
|
|
812
|
+
diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
|
|
813
|
+
}
|
|
814
|
+
catch { }
|
|
815
|
+
options.onDiagnostics?.(diagnosticsReport);
|
|
816
|
+
}
|
|
630
817
|
return svg;
|
|
631
818
|
}
|
|
632
819
|
const copyStart = now();
|
|
633
820
|
const bytes = new Uint8Array(result);
|
|
634
821
|
addProfile("copyResult", now() - copyStart);
|
|
635
822
|
options.onProfile?.(profile);
|
|
823
|
+
if (diagnosticsReport) {
|
|
824
|
+
try {
|
|
825
|
+
diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
|
|
826
|
+
}
|
|
827
|
+
catch { }
|
|
828
|
+
options.onDiagnostics?.(diagnosticsReport);
|
|
829
|
+
}
|
|
636
830
|
return bytes;
|
|
637
831
|
}
|
|
638
832
|
finally {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ResourceResolver, ResolvedFontResult } from "./core.js";
|
|
2
|
+
export interface ResourceCacheEntry {
|
|
3
|
+
data: Uint8Array | ResolvedFontResult;
|
|
4
|
+
bytes: number;
|
|
5
|
+
created: number;
|
|
6
|
+
lastHit: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ResourceCacheOptions {
|
|
9
|
+
/** Maximum number of entries to keep in cache. Default: 100 */
|
|
10
|
+
maxEntries?: number;
|
|
11
|
+
/** Maximum total bytes to keep in cache. Default: 64MB */
|
|
12
|
+
maxBytes?: number;
|
|
13
|
+
/** Time to live in milliseconds. Default: 0 (no TTL) */
|
|
14
|
+
ttl?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* In-memory resource cache that works in all environments.
|
|
18
|
+
*/
|
|
19
|
+
export declare class MemoryResourceCache {
|
|
20
|
+
private cache;
|
|
21
|
+
private totalBytes;
|
|
22
|
+
private options;
|
|
23
|
+
constructor(options?: ResourceCacheOptions);
|
|
24
|
+
get(url: string): Promise<Uint8Array | ResolvedFontResult | null>;
|
|
25
|
+
set(url: string, data: Uint8Array | ResolvedFontResult): Promise<void>;
|
|
26
|
+
private delete;
|
|
27
|
+
private evict;
|
|
28
|
+
private calculateBytes;
|
|
29
|
+
/**
|
|
30
|
+
* Returns a ResourceResolver that wraps another resolver (or the default one) with this cache.
|
|
31
|
+
*/
|
|
32
|
+
wrap(resolver?: ResourceResolver): ResourceResolver;
|
|
33
|
+
/**
|
|
34
|
+
* Clear the cache.
|
|
35
|
+
*/
|
|
36
|
+
clear(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Get cache statistics.
|
|
39
|
+
*/
|
|
40
|
+
getStats(): {
|
|
41
|
+
entries: number;
|
|
42
|
+
totalBytes: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Composes multiple resource resolvers into a single one.
|
|
47
|
+
* They are executed in order; each one can choose to call the next one in the chain.
|
|
48
|
+
*/
|
|
49
|
+
export declare function composeResourceResolvers(...resolvers: ResourceResolver[]): ResourceResolver;
|
|
50
|
+
/**
|
|
51
|
+
* CacheStorage adapter for browser and Cloudflare Workers.
|
|
52
|
+
*/
|
|
53
|
+
export declare class CacheStorageResourceCache {
|
|
54
|
+
private cacheName;
|
|
55
|
+
constructor(cacheName?: string);
|
|
56
|
+
private getCache;
|
|
57
|
+
get(url: string): Promise<Uint8Array | null>;
|
|
58
|
+
set(url: string, data: Uint8Array): Promise<void>;
|
|
59
|
+
wrap(resolver?: ResourceResolver): ResourceResolver;
|
|
60
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory resource cache that works in all environments.
|
|
3
|
+
*/
|
|
4
|
+
export class MemoryResourceCache {
|
|
5
|
+
cache = new Map();
|
|
6
|
+
totalBytes = 0;
|
|
7
|
+
options;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.options = {
|
|
10
|
+
maxEntries: options.maxEntries ?? 100,
|
|
11
|
+
maxBytes: options.maxBytes ?? 64 * 1024 * 1024,
|
|
12
|
+
ttl: options.ttl ?? 0,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async get(url) {
|
|
16
|
+
const entry = this.cache.get(url);
|
|
17
|
+
if (!entry)
|
|
18
|
+
return null;
|
|
19
|
+
if (this.options.ttl && Date.now() - entry.created > this.options.ttl) {
|
|
20
|
+
this.delete(url);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
entry.lastHit = Date.now();
|
|
24
|
+
return entry.data;
|
|
25
|
+
}
|
|
26
|
+
async set(url, data) {
|
|
27
|
+
const bytes = this.calculateBytes(data);
|
|
28
|
+
if (this.options.maxBytes && bytes > this.options.maxBytes)
|
|
29
|
+
return;
|
|
30
|
+
this.evict(bytes);
|
|
31
|
+
this.cache.set(url, {
|
|
32
|
+
data,
|
|
33
|
+
bytes,
|
|
34
|
+
created: Date.now(),
|
|
35
|
+
lastHit: Date.now(),
|
|
36
|
+
});
|
|
37
|
+
this.totalBytes += bytes;
|
|
38
|
+
}
|
|
39
|
+
delete(url) {
|
|
40
|
+
const entry = this.cache.get(url);
|
|
41
|
+
if (entry) {
|
|
42
|
+
this.totalBytes -= entry.bytes;
|
|
43
|
+
this.cache.delete(url);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
evict(incomingBytes) {
|
|
47
|
+
if (this.cache.size >= this.options.maxEntries || (this.options.maxBytes && this.totalBytes + incomingBytes > this.options.maxBytes)) {
|
|
48
|
+
const sorted = Array.from(this.cache.entries()).sort((a, b) => a[1].lastHit - b[1].lastHit);
|
|
49
|
+
for (const [url, entry] of sorted) {
|
|
50
|
+
this.delete(url);
|
|
51
|
+
if (this.cache.size < this.options.maxEntries && (!this.options.maxBytes || this.totalBytes + incomingBytes <= this.options.maxBytes)) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
calculateBytes(data) {
|
|
58
|
+
if (data instanceof Uint8Array)
|
|
59
|
+
return data.byteLength;
|
|
60
|
+
let sum = data.css.byteLength;
|
|
61
|
+
for (const f of data.fonts)
|
|
62
|
+
sum += f.data.byteLength;
|
|
63
|
+
return sum;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns a ResourceResolver that wraps another resolver (or the default one) with this cache.
|
|
67
|
+
*/
|
|
68
|
+
wrap(resolver) {
|
|
69
|
+
return async (resource, defaultResolver) => {
|
|
70
|
+
const cached = await this.get(resource.url);
|
|
71
|
+
if (cached)
|
|
72
|
+
return cached;
|
|
73
|
+
const result = await (resolver ? resolver(resource, defaultResolver) : defaultResolver(resource));
|
|
74
|
+
if (result) {
|
|
75
|
+
// Normalize to Uint8Array if it's an ArrayBufferView but not ResolvedFontResult
|
|
76
|
+
let dataToCache;
|
|
77
|
+
if (typeof result.css !== 'undefined') {
|
|
78
|
+
dataToCache = result;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const view = result;
|
|
82
|
+
dataToCache = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
83
|
+
}
|
|
84
|
+
await this.set(resource.url, dataToCache);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Clear the cache.
|
|
91
|
+
*/
|
|
92
|
+
clear() {
|
|
93
|
+
this.cache.clear();
|
|
94
|
+
this.totalBytes = 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get cache statistics.
|
|
98
|
+
*/
|
|
99
|
+
getStats() {
|
|
100
|
+
return {
|
|
101
|
+
entries: this.cache.size,
|
|
102
|
+
totalBytes: this.totalBytes,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Composes multiple resource resolvers into a single one.
|
|
108
|
+
* They are executed in order; each one can choose to call the next one in the chain.
|
|
109
|
+
*/
|
|
110
|
+
export function composeResourceResolvers(...resolvers) {
|
|
111
|
+
return (resource, defaultResolver) => {
|
|
112
|
+
const run = (i) => {
|
|
113
|
+
const resolver = resolvers[i];
|
|
114
|
+
if (resolver) {
|
|
115
|
+
return resolver(resource, (r) => run(i + 1));
|
|
116
|
+
}
|
|
117
|
+
return defaultResolver(resource);
|
|
118
|
+
};
|
|
119
|
+
return run(0);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* CacheStorage adapter for browser and Cloudflare Workers.
|
|
124
|
+
*/
|
|
125
|
+
export class CacheStorageResourceCache {
|
|
126
|
+
cacheName;
|
|
127
|
+
constructor(cacheName = "satoru-resources") {
|
|
128
|
+
this.cacheName = cacheName;
|
|
129
|
+
}
|
|
130
|
+
async getCache() {
|
|
131
|
+
return await caches.open(this.cacheName);
|
|
132
|
+
}
|
|
133
|
+
async get(url) {
|
|
134
|
+
try {
|
|
135
|
+
const cache = await this.getCache();
|
|
136
|
+
const response = await cache.match(url);
|
|
137
|
+
if (response) {
|
|
138
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
console.warn("CacheStorage error:", e);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
async set(url, data) {
|
|
147
|
+
try {
|
|
148
|
+
const cache = await this.getCache();
|
|
149
|
+
await cache.put(url, new Response(data, {
|
|
150
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
console.warn("CacheStorage error:", e);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
wrap(resolver) {
|
|
158
|
+
return async (resource, defaultResolver) => {
|
|
159
|
+
// Note: CacheStorage only supports Uint8Array easily, not ResolvedFontResult
|
|
160
|
+
if (resource.type === "font") {
|
|
161
|
+
// Fonts are complex because they might return multiple files.
|
|
162
|
+
// For now we don't cache ResolvedFontResult in CacheStorage.
|
|
163
|
+
return resolver ? resolver(resource, defaultResolver) : defaultResolver(resource);
|
|
164
|
+
}
|
|
165
|
+
const cached = await this.get(resource.url);
|
|
166
|
+
if (cached)
|
|
167
|
+
return cached;
|
|
168
|
+
const result = await (resolver ? resolver(resource, defaultResolver) : defaultResolver(resource));
|
|
169
|
+
if (result && result instanceof Uint8Array) {
|
|
170
|
+
await this.set(resource.url, result);
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/satoru-single.js
CHANGED
|
Binary file
|