satoru-render 1.0.9 → 1.0.11
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/dist/core.d.ts +7 -2
- package/dist/core.js +157 -76
- package/dist/satoru-single.js +0 -0
- package/dist/satoru.js +1 -1
- package/dist/satoru.wasm +0 -0
- package/dist/web-workers.js +152 -65
- package/dist/workers-parent.js +125 -38
- package/package.json +5 -5
package/dist/core.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { LogLevel } from "./log-level.js";
|
|
|
2
2
|
export interface SatoruModule {
|
|
3
3
|
create_instance: () => any;
|
|
4
4
|
destroy_instance: (inst: any) => void;
|
|
5
|
-
collect_resources: (inst: any, html: string, width: number, height: number) => void;
|
|
6
|
-
get_pending_resources: (inst: any) =>
|
|
5
|
+
collect_resources: (inst: any, html: string, width: number, height: number, mediaType: number) => void;
|
|
6
|
+
get_pending_resources: (inst: any) => Uint8Array | null;
|
|
7
7
|
add_resource: (inst: any, url: string, type: number, data: Uint8Array) => void;
|
|
8
8
|
scan_css: (inst: any, css: string) => void;
|
|
9
9
|
load_font: (inst: any, name: string, data: Uint8Array) => void;
|
|
@@ -87,6 +87,10 @@ export interface RenderOptions {
|
|
|
87
87
|
onLog?: (level: LogLevel, message: string) => void;
|
|
88
88
|
/** Media type for CSS @media queries (default: "screen") */
|
|
89
89
|
mediaType?: "screen" | "print";
|
|
90
|
+
/** Collect coarse render timings for diagnostics */
|
|
91
|
+
profile?: boolean;
|
|
92
|
+
/** Receives coarse render timings when profile is enabled */
|
|
93
|
+
onProfile?: (profile: Record<string, number>) => void;
|
|
90
94
|
}
|
|
91
95
|
export declare const DEFAULT_FONT_MAP: Record<string, string>;
|
|
92
96
|
export declare function resolveGoogleFonts(resource: RequiredResource, userAgent?: string): Promise<ResolvedFontResult | Uint8Array | null>;
|
|
@@ -123,6 +127,7 @@ export declare abstract class SatoruBase {
|
|
|
123
127
|
mediaType?: "screen" | "print";
|
|
124
128
|
}): Promise<string | Uint8Array>;
|
|
125
129
|
destroyInstance(inst: any): Promise<void>;
|
|
130
|
+
loadFont(name: string, data: Uint8Array): Promise<void>;
|
|
126
131
|
loadFallbackFont(data: Uint8Array): Promise<void>;
|
|
127
132
|
protected parseColor(color?: string): number;
|
|
128
133
|
protected abstract resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | ResolvedFontResult | null>;
|
package/dist/core.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { LogLevel } from "./log-level.js";
|
|
2
|
+
const emojiUrl = "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2";
|
|
2
3
|
export const DEFAULT_FONT_MAP = {
|
|
3
4
|
"sans-serif": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP",
|
|
4
5
|
serif: "https://fonts.googleapis.com/css2?family=Noto+Serif+JP",
|
|
5
6
|
monospace: "https://fonts.googleapis.com/css2?family=M+PLUS+1+Code",
|
|
6
7
|
cursive: "https://fonts.googleapis.com/css2?family=Yuji+Syuku",
|
|
7
8
|
fantasy: "https://fonts.googleapis.com/css2?family=Reggae+One",
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
"Noto Color Emoji": emojiUrl,
|
|
10
|
+
emoji: emojiUrl,
|
|
11
|
+
"Noto Emoji": emojiUrl,
|
|
12
|
+
notocoloremoji: emojiUrl,
|
|
13
|
+
notoemoji: emojiUrl,
|
|
10
14
|
};
|
|
11
15
|
/**
|
|
12
16
|
* Parse unicode-range string into an array of [start, end] codepoint ranges.
|
|
@@ -256,6 +260,16 @@ export class SatoruBase {
|
|
|
256
260
|
const mod = await this.getModule();
|
|
257
261
|
mod.destroy_instance(inst);
|
|
258
262
|
}
|
|
263
|
+
async loadFont(name, data) {
|
|
264
|
+
const mod = await this.getModule();
|
|
265
|
+
const inst = mod.create_instance();
|
|
266
|
+
try {
|
|
267
|
+
mod.load_font(inst, name, data);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
mod.destroy_instance(inst);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
259
273
|
async loadFallbackFont(data) {
|
|
260
274
|
const mod = await this.getModule();
|
|
261
275
|
const inst = mod.create_instance();
|
|
@@ -298,6 +312,16 @@ export class SatoruBase {
|
|
|
298
312
|
}
|
|
299
313
|
async render(options) {
|
|
300
314
|
let { format = "svg", value, url, baseUrl } = options;
|
|
315
|
+
const profileEnabled = options.profile === true;
|
|
316
|
+
const profile = {};
|
|
317
|
+
const now = () => typeof performance !== "undefined" && performance.now
|
|
318
|
+
? performance.now()
|
|
319
|
+
: Date.now();
|
|
320
|
+
const addProfile = (name, elapsed) => {
|
|
321
|
+
if (!profileEnabled)
|
|
322
|
+
return;
|
|
323
|
+
profile[name] = (profile[name] ?? 0) + elapsed;
|
|
324
|
+
};
|
|
301
325
|
if (format === "pdf" && Array.isArray(value) && value.length > 1) {
|
|
302
326
|
const pagePdfs = [];
|
|
303
327
|
const SatoruClass = this.constructor;
|
|
@@ -323,7 +347,7 @@ export class SatoruBase {
|
|
|
323
347
|
}
|
|
324
348
|
}
|
|
325
349
|
let mod = await this.getModule();
|
|
326
|
-
const { width, height = 0, fonts, images, css, logLevel, onLog
|
|
350
|
+
const { width, height = 0, fonts, images, css, logLevel, onLog } = options;
|
|
327
351
|
if (!options.userAgent) {
|
|
328
352
|
options.userAgent =
|
|
329
353
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
@@ -347,24 +371,6 @@ export class SatoruBase {
|
|
|
347
371
|
const instancePtr = mod.create_instance();
|
|
348
372
|
mod.set_font_map(instancePtr, this.currentFontMap);
|
|
349
373
|
try {
|
|
350
|
-
if (fonts) {
|
|
351
|
-
for (const f of fonts) {
|
|
352
|
-
mod.load_font(instancePtr, f.name, f.data);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
if (options.fallbackFonts) {
|
|
356
|
-
for (const data of options.fallbackFonts) {
|
|
357
|
-
mod.load_fallback_font(instancePtr, data);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
if (images) {
|
|
361
|
-
for (const img of images) {
|
|
362
|
-
mod.load_image(instancePtr, img.name, img.url, img.width ?? 0, img.height ?? 0);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
if (css) {
|
|
366
|
-
mod.scan_css(instancePtr, css);
|
|
367
|
-
}
|
|
368
374
|
const defaultResolver = (r) => this.resolveDefaultResource(r, baseUrl, options.userAgent);
|
|
369
375
|
const resolver = options.resolveResource
|
|
370
376
|
? async (r) => {
|
|
@@ -374,48 +380,58 @@ export class SatoruBase {
|
|
|
374
380
|
const cachedResolver = async (r) => {
|
|
375
381
|
const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
|
|
376
382
|
const cached = this.resourceCache.get(cacheKey);
|
|
377
|
-
if (cached)
|
|
383
|
+
if (cached) {
|
|
384
|
+
addProfile("resourceCacheHitsCount", 1);
|
|
378
385
|
return cached;
|
|
386
|
+
}
|
|
387
|
+
const resolveStart = now();
|
|
379
388
|
const result = await resolver(r);
|
|
389
|
+
addProfile("resolveResources", now() - resolveStart);
|
|
380
390
|
if (result) {
|
|
381
391
|
if (result instanceof Uint8Array) {
|
|
382
392
|
this.resourceCache.set(cacheKey, result);
|
|
383
393
|
}
|
|
384
|
-
else if ("css" in result &&
|
|
385
|
-
"fonts" in result) {
|
|
394
|
+
else if ("css" in result && "fonts" in result) {
|
|
386
395
|
this.resourceCache.set(cacheKey, result);
|
|
387
396
|
}
|
|
388
397
|
}
|
|
389
398
|
return result;
|
|
390
399
|
};
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
400
|
+
if (this.currentFontMap["notocoloremoji"]) {
|
|
401
|
+
const emojiUrl = this.currentFontMap["notocoloremoji"];
|
|
402
|
+
const res = await cachedResolver({
|
|
403
|
+
type: "font",
|
|
404
|
+
url: emojiUrl,
|
|
405
|
+
name: "notocoloremoji",
|
|
406
|
+
});
|
|
407
|
+
if (res && res instanceof Uint8Array) {
|
|
408
|
+
mod.load_font(instancePtr, "notocoloremoji", res);
|
|
409
|
+
}
|
|
410
|
+
else if (res && "fonts" in res) {
|
|
411
|
+
for (const f of res.fonts) {
|
|
412
|
+
mod.load_font(instancePtr, "notocoloremoji", f.data);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (fonts) {
|
|
417
|
+
for (const f of fonts) {
|
|
418
|
+
mod.load_font(instancePtr, f.name, f.data);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (options.fallbackFonts) {
|
|
422
|
+
for (const data of options.fallbackFonts) {
|
|
423
|
+
mod.load_fallback_font(instancePtr, data);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (images) {
|
|
427
|
+
for (const img of images) {
|
|
428
|
+
mod.load_image(instancePtr, img.name, img.url, img.width ?? 0, img.height ?? 0);
|
|
418
429
|
}
|
|
430
|
+
}
|
|
431
|
+
if (css) {
|
|
432
|
+
mod.scan_css(instancePtr, css);
|
|
433
|
+
}
|
|
434
|
+
const loadResourceData = (r, uint8) => {
|
|
419
435
|
let typeInt = 1; // Font
|
|
420
436
|
if (r.type === "image")
|
|
421
437
|
typeInt = 2;
|
|
@@ -429,17 +445,51 @@ export class SatoruBase {
|
|
|
429
445
|
for (const rawHtml of inputHtmls) {
|
|
430
446
|
let processedHtml = rawHtml;
|
|
431
447
|
for (let i = 0; i < 10; i++) {
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
448
|
+
addProfile("collectResourcesCount", 1);
|
|
449
|
+
const collectStart = now();
|
|
450
|
+
mod.collect_resources(instancePtr, processedHtml, width, height, options.mediaType === "print" ? 1 : 0);
|
|
451
|
+
addProfile("collectResources", now() - collectStart);
|
|
452
|
+
const pendingStart = now();
|
|
453
|
+
const binary = mod.get_pending_resources(instancePtr);
|
|
454
|
+
addProfile("getPendingResources", now() - pendingStart);
|
|
455
|
+
if (!binary)
|
|
435
456
|
break;
|
|
436
|
-
const
|
|
457
|
+
const parseStart = now();
|
|
458
|
+
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
|
|
459
|
+
let offset = 0;
|
|
460
|
+
const count = view.getUint32(offset, true);
|
|
461
|
+
offset += 4;
|
|
462
|
+
const resources = [];
|
|
463
|
+
for (let j = 0; j < count; j++) {
|
|
464
|
+
const typeInt = view.getUint8(offset++);
|
|
465
|
+
const redraw_on_ready = view.getUint8(offset++) !== 0;
|
|
466
|
+
const urlLen = view.getUint32(offset, true);
|
|
467
|
+
offset += 4;
|
|
468
|
+
const url = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
|
|
469
|
+
offset += urlLen;
|
|
470
|
+
const nameLen = view.getUint32(offset, true);
|
|
471
|
+
offset += 4;
|
|
472
|
+
const name = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
|
|
473
|
+
offset += nameLen;
|
|
474
|
+
const charsLen = view.getUint32(offset, true);
|
|
475
|
+
offset += 4;
|
|
476
|
+
const characters = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
|
|
477
|
+
offset += charsLen;
|
|
478
|
+
let type = "font";
|
|
479
|
+
if (typeInt === 2)
|
|
480
|
+
type = "image";
|
|
481
|
+
else if (typeInt === 3)
|
|
482
|
+
type = "css";
|
|
483
|
+
resources.push({ type, url, name, characters, redraw_on_ready });
|
|
484
|
+
}
|
|
437
485
|
const pending = resources.filter((r) => {
|
|
438
486
|
const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
|
|
439
487
|
return !resolvedResources.has(key);
|
|
440
488
|
});
|
|
489
|
+
addProfile("parsePendingResources", now() - parseStart);
|
|
441
490
|
if (pending.length === 0)
|
|
442
491
|
break;
|
|
492
|
+
const loadStart = now();
|
|
443
493
|
await Promise.all(pending.map(async (r) => {
|
|
444
494
|
try {
|
|
445
495
|
if (r.url.startsWith("data:")) {
|
|
@@ -468,27 +518,29 @@ export class SatoruBase {
|
|
|
468
518
|
return;
|
|
469
519
|
}
|
|
470
520
|
// Handle regular Uint8Array / ArrayBufferView
|
|
471
|
-
if (data instanceof Uint8Array ||
|
|
472
|
-
ArrayBuffer.isView(data)) {
|
|
521
|
+
if (data instanceof Uint8Array || ArrayBuffer.isView(data)) {
|
|
473
522
|
let finalUint8 = data instanceof Uint8Array
|
|
474
523
|
? data
|
|
475
524
|
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
476
525
|
if (r.type === "css") {
|
|
477
526
|
const cssText = new TextDecoder().decode(finalUint8);
|
|
478
|
-
const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(r.url) ||
|
|
527
|
+
const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(r.url) ||
|
|
528
|
+
r.url.startsWith("data:");
|
|
479
529
|
let cssBaseUrl = r.url;
|
|
480
530
|
if (!isAbsolute && baseUrl) {
|
|
481
531
|
try {
|
|
482
532
|
const base = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseUrl)
|
|
483
533
|
? baseUrl
|
|
484
|
-
: new URL(`file:///${baseUrl.replace(/\\/g, "/")}`)
|
|
534
|
+
: new URL(`file:///${baseUrl.replace(/\\/g, "/")}`)
|
|
535
|
+
.href;
|
|
485
536
|
cssBaseUrl = new URL(r.url, base).href;
|
|
486
537
|
}
|
|
487
538
|
catch (e) { }
|
|
488
539
|
}
|
|
489
540
|
if (cssBaseUrl) {
|
|
490
541
|
const rewrittenCss = cssText.replace(/url\(['"]?([^'")]+)['"]?\)/g, (match, urlParam) => {
|
|
491
|
-
if (urlParam.startsWith("data:") ||
|
|
542
|
+
if (urlParam.startsWith("data:") ||
|
|
543
|
+
/^[a-z][a-z0-9+.-]*:/i.test(urlParam)) {
|
|
492
544
|
return match;
|
|
493
545
|
}
|
|
494
546
|
try {
|
|
@@ -509,7 +561,9 @@ export class SatoruBase {
|
|
|
509
561
|
console.warn(`Failed to resolve resource: ${r.url}`, e);
|
|
510
562
|
}
|
|
511
563
|
}));
|
|
564
|
+
addProfile("loadPendingResources", now() - loadStart);
|
|
512
565
|
}
|
|
566
|
+
const stripStart = now();
|
|
513
567
|
const resolvedUrls = new Set();
|
|
514
568
|
resolvedResources.forEach((key) => {
|
|
515
569
|
const parts = key.split(":");
|
|
@@ -523,6 +577,7 @@ export class SatoruBase {
|
|
|
523
577
|
processedHtml = processedHtml.replace(linkRegex, "");
|
|
524
578
|
});
|
|
525
579
|
processedHtmls.push(processedHtml);
|
|
580
|
+
addProfile("stripResolvedLinks", now() - stripStart);
|
|
526
581
|
}
|
|
527
582
|
const formatMap = {
|
|
528
583
|
svg: 0,
|
|
@@ -530,29 +585,55 @@ export class SatoruBase {
|
|
|
530
585
|
webp: 2,
|
|
531
586
|
pdf: 3,
|
|
532
587
|
};
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
588
|
+
const renderStart = now();
|
|
589
|
+
const result = (processedHtmls.length === 1)
|
|
590
|
+
? mod.render_from_state(instancePtr, width, height, formatMap[format] ?? 0, {
|
|
591
|
+
svgTextToPaths: options.textToPaths ?? true,
|
|
592
|
+
outputWidth: options.outputWidth ?? 0,
|
|
593
|
+
outputHeight: options.outputHeight ?? 0,
|
|
594
|
+
fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
|
|
595
|
+
cropX: options.crop?.x ?? 0,
|
|
596
|
+
cropY: options.crop?.y ?? 0,
|
|
597
|
+
cropWidth: options.crop?.width ?? 0,
|
|
598
|
+
cropHeight: options.crop?.height ?? 0,
|
|
599
|
+
fitPositionX: options.fitPosition?.x ?? 0.5,
|
|
600
|
+
fitPositionY: options.fitPosition?.y ?? 0.5,
|
|
601
|
+
backgroundColor: this.parseColor(options.backgroundColor),
|
|
602
|
+
mediaType: options.mediaType === "print" ? 1 : 0,
|
|
603
|
+
})
|
|
604
|
+
: mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
|
|
605
|
+
svgTextToPaths: options.textToPaths ?? true,
|
|
606
|
+
outputWidth: options.outputWidth ?? 0,
|
|
607
|
+
outputHeight: options.outputHeight ?? 0,
|
|
608
|
+
fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
|
|
609
|
+
cropX: options.crop?.x ?? 0,
|
|
610
|
+
cropY: options.crop?.y ?? 0,
|
|
611
|
+
cropWidth: options.crop?.width ?? 0,
|
|
612
|
+
cropHeight: options.crop?.height ?? 0,
|
|
613
|
+
fitPositionX: options.fitPosition?.x ?? 0.5,
|
|
614
|
+
fitPositionY: options.fitPosition?.y ?? 0.5,
|
|
615
|
+
backgroundColor: this.parseColor(options.backgroundColor),
|
|
616
|
+
mediaType: options.mediaType === "print" ? 1 : 0,
|
|
617
|
+
});
|
|
618
|
+
addProfile("wasmRender", now() - renderStart);
|
|
547
619
|
if (!result) {
|
|
620
|
+
options.onProfile?.(profile);
|
|
548
621
|
if (format === "svg")
|
|
549
622
|
return "";
|
|
550
623
|
return new Uint8Array();
|
|
551
624
|
}
|
|
552
625
|
if (format === "svg") {
|
|
553
|
-
|
|
626
|
+
const decodeStart = now();
|
|
627
|
+
const svg = new TextDecoder().decode(result);
|
|
628
|
+
addProfile("decodeResult", now() - decodeStart);
|
|
629
|
+
options.onProfile?.(profile);
|
|
630
|
+
return svg;
|
|
554
631
|
}
|
|
555
|
-
|
|
632
|
+
const copyStart = now();
|
|
633
|
+
const bytes = new Uint8Array(result);
|
|
634
|
+
addProfile("copyResult", now() - copyStart);
|
|
635
|
+
options.onProfile?.(profile);
|
|
636
|
+
return bytes;
|
|
556
637
|
}
|
|
557
638
|
finally {
|
|
558
639
|
mod.destroy_instance(instancePtr);
|
package/dist/satoru-single.js
CHANGED
|
Binary file
|