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 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) => string;
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
- emoji: "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
9
- "Noto Color Emoji": "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
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, } = options;
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
- const loadResourceData = (r, uint8) => {
392
- if (r.type === "image" &&
393
- typeof createImageBitmap !== "undefined" &&
394
- typeof OffscreenCanvas !== "undefined") {
395
- return (async () => {
396
- try {
397
- const blob = new Blob([uint8.buffer]);
398
- const bitmap = await createImageBitmap(blob);
399
- const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
400
- const ctx = canvas.getContext("2d");
401
- if (ctx) {
402
- ctx.drawImage(bitmap, 0, 0);
403
- const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
404
- mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
405
- return;
406
- }
407
- }
408
- catch (e) {
409
- // fall through
410
- }
411
- let typeInt = 1; // Font
412
- if (r.type === "image")
413
- typeInt = 2;
414
- if (r.type === "css")
415
- typeInt = 3;
416
- mod.add_resource(instancePtr, r.url, typeInt, uint8);
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
- mod.collect_resources(instancePtr, processedHtml, width, height);
433
- const json = mod.get_pending_resources(instancePtr);
434
- if (!json)
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 resources = JSON.parse(json);
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) || r.url.startsWith("data:");
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, "/")}`).href;
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:") || /^[a-z][a-z0-9+.-]*:/i.test(urlParam)) {
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 result = mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
534
- svgTextToPaths: options.textToPaths ?? true,
535
- outputWidth: options.outputWidth ?? 0,
536
- outputHeight: options.outputHeight ?? 0,
537
- fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
538
- cropX: options.crop?.x ?? 0,
539
- cropY: options.crop?.y ?? 0,
540
- cropWidth: options.crop?.width ?? 0,
541
- cropHeight: options.crop?.height ?? 0,
542
- fitPositionX: options.fitPosition?.x ?? 0.5,
543
- fitPositionY: options.fitPosition?.y ?? 0.5,
544
- backgroundColor: this.parseColor(options.backgroundColor),
545
- mediaType: options.mediaType === "print" ? 1 : 0,
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
- return new TextDecoder().decode(result);
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
- return new Uint8Array(result);
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);
Binary file