satoru-render 1.0.10 → 1.0.12

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/cli.js CHANGED
File without changes
package/dist/core.d.ts CHANGED
@@ -2,8 +2,10 @@ 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_collect_profile: (inst: any) => string;
7
+ set_collect_profile_enabled: (inst: any, enabled: boolean) => void;
8
+ get_pending_resources: (inst: any) => Uint8Array | null;
7
9
  add_resource: (inst: any, url: string, type: number, data: Uint8Array) => void;
8
10
  scan_css: (inst: any, css: string) => void;
9
11
  load_font: (inst: any, name: string, data: Uint8Array) => void;
@@ -87,6 +89,10 @@ export interface RenderOptions {
87
89
  onLog?: (level: LogLevel, message: string) => void;
88
90
  /** Media type for CSS @media queries (default: "screen") */
89
91
  mediaType?: "screen" | "print";
92
+ /** Collect coarse render timings for diagnostics */
93
+ profile?: boolean;
94
+ /** Receives coarse render timings when profile is enabled */
95
+ onProfile?: (profile: Record<string, number>) => void;
90
96
  }
91
97
  export declare const DEFAULT_FONT_MAP: Record<string, string>;
92
98
  export declare function resolveGoogleFonts(resource: RequiredResource, userAgent?: string): Promise<ResolvedFontResult | Uint8Array | null>;
package/dist/core.js CHANGED
@@ -312,6 +312,16 @@ export class SatoruBase {
312
312
  }
313
313
  async render(options) {
314
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
+ };
315
325
  if (format === "pdf" && Array.isArray(value) && value.length > 1) {
316
326
  const pagePdfs = [];
317
327
  const SatoruClass = this.constructor;
@@ -360,6 +370,7 @@ export class SatoruBase {
360
370
  this.currentFontMap = options.fontMap ?? DEFAULT_FONT_MAP;
361
371
  const instancePtr = mod.create_instance();
362
372
  mod.set_font_map(instancePtr, this.currentFontMap);
373
+ mod.set_collect_profile_enabled(instancePtr, profileEnabled);
363
374
  try {
364
375
  const defaultResolver = (r) => this.resolveDefaultResource(r, baseUrl, options.userAgent);
365
376
  const resolver = options.resolveResource
@@ -370,9 +381,13 @@ export class SatoruBase {
370
381
  const cachedResolver = async (r) => {
371
382
  const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
372
383
  const cached = this.resourceCache.get(cacheKey);
373
- if (cached)
384
+ if (cached) {
385
+ addProfile("resourceCacheHitsCount", 1);
374
386
  return cached;
387
+ }
388
+ const resolveStart = now();
375
389
  const result = await resolver(r);
390
+ addProfile("resolveResources", now() - resolveStart);
376
391
  if (result) {
377
392
  if (result instanceof Uint8Array) {
378
393
  this.resourceCache.set(cacheKey, result);
@@ -418,33 +433,6 @@ export class SatoruBase {
418
433
  mod.scan_css(instancePtr, css);
419
434
  }
420
435
  const loadResourceData = (r, uint8) => {
421
- if (r.type === "image" &&
422
- typeof createImageBitmap !== "undefined" &&
423
- typeof OffscreenCanvas !== "undefined") {
424
- return (async () => {
425
- try {
426
- const blob = new Blob([uint8.buffer]);
427
- const bitmap = await createImageBitmap(blob);
428
- const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
429
- const ctx = canvas.getContext("2d");
430
- if (ctx) {
431
- ctx.drawImage(bitmap, 0, 0);
432
- const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
433
- mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
434
- return;
435
- }
436
- }
437
- catch (e) {
438
- // fall through
439
- }
440
- let typeInt = 1; // Font
441
- if (r.type === "image")
442
- typeInt = 2;
443
- if (r.type === "css")
444
- typeInt = 3;
445
- mod.add_resource(instancePtr, r.url, typeInt, uint8);
446
- })();
447
- }
448
436
  let typeInt = 1; // Font
449
437
  if (r.type === "image")
450
438
  typeInt = 2;
@@ -454,21 +442,89 @@ export class SatoruBase {
454
442
  };
455
443
  const inputHtmls = Array.isArray(value) ? value : [value];
456
444
  const processedHtmls = [];
445
+ const utf8Decoder = new TextDecoder();
446
+ const utf8Encoder = new TextEncoder();
457
447
  const resolvedResources = new Set();
458
448
  for (const rawHtml of inputHtmls) {
459
449
  let processedHtml = rawHtml;
460
450
  for (let i = 0; i < 10; i++) {
461
- mod.collect_resources(instancePtr, processedHtml, width, height);
462
- const json = mod.get_pending_resources(instancePtr);
463
- if (!json)
451
+ addProfile("collectResourcesCount", 1);
452
+ const collectStart = now();
453
+ mod.collect_resources(instancePtr, processedHtml, width, height, options.mediaType === "print" ? 1 : 0);
454
+ const collectElapsed = now() - collectStart;
455
+ addProfile("collectResources", collectElapsed);
456
+ addProfile(`collectResourcesRound${i + 1}`, collectElapsed);
457
+ if (profileEnabled) {
458
+ try {
459
+ const collectProfile = JSON.parse(mod.get_collect_profile(instancePtr));
460
+ for (const [key, value] of Object.entries(collectProfile)) {
461
+ addProfile(key, value);
462
+ addProfile(`${key}Round${i + 1}`, value);
463
+ }
464
+ }
465
+ catch { }
466
+ }
467
+ const pendingStart = now();
468
+ const binary = mod.get_pending_resources(instancePtr);
469
+ addProfile("getPendingResources", now() - pendingStart);
470
+ if (!binary)
464
471
  break;
465
- const resources = JSON.parse(json);
472
+ const parseStart = now();
473
+ const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
474
+ let offset = 0;
475
+ const count = view.getUint32(offset, true);
476
+ offset += 4;
477
+ const resources = [];
478
+ for (let j = 0; j < count; j++) {
479
+ const typeInt = view.getUint8(offset++);
480
+ const redraw_on_ready = view.getUint8(offset++) !== 0;
481
+ const urlLen = view.getUint32(offset, true);
482
+ offset += 4;
483
+ const url = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
484
+ offset += urlLen;
485
+ const nameLen = view.getUint32(offset, true);
486
+ offset += 4;
487
+ const name = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
488
+ offset += nameLen;
489
+ const charsLen = view.getUint32(offset, true);
490
+ offset += 4;
491
+ const characters = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
492
+ offset += charsLen;
493
+ let type = "font";
494
+ if (typeInt === 2)
495
+ type = "image";
496
+ else if (typeInt === 3)
497
+ type = "css";
498
+ resources.push({ type, url, name, characters, redraw_on_ready });
499
+ }
466
500
  const pending = resources.filter((r) => {
467
501
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
502
+ if (r.type === "font" && resolvedResources.has(`font:${r.url}:`)) {
503
+ return false;
504
+ }
468
505
  return !resolvedResources.has(key);
469
506
  });
507
+ addProfile("pendingResourcesCount", pending.length);
508
+ addProfile(`pendingResourcesRound${i + 1}Count`, pending.length);
509
+ for (const r of pending) {
510
+ if (r.type === "font") {
511
+ addProfile("pendingFontResourcesCount", 1);
512
+ addProfile(`pendingFontResourcesRound${i + 1}Count`, 1);
513
+ }
514
+ else if (r.type === "css") {
515
+ addProfile("pendingCssResourcesCount", 1);
516
+ addProfile(`pendingCssResourcesRound${i + 1}Count`, 1);
517
+ }
518
+ else if (r.type === "image") {
519
+ addProfile("pendingImageResourcesCount", 1);
520
+ addProfile(`pendingImageResourcesRound${i + 1}Count`, 1);
521
+ }
522
+ }
523
+ addProfile("parsePendingResources", now() - parseStart);
470
524
  if (pending.length === 0)
471
525
  break;
526
+ addProfile("pendingResourceRoundsCount", 1);
527
+ const loadStart = now();
472
528
  await Promise.all(pending.map(async (r) => {
473
529
  try {
474
530
  if (r.url.startsWith("data:")) {
@@ -476,6 +532,9 @@ export class SatoruBase {
476
532
  }
477
533
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
478
534
  resolvedResources.add(key);
535
+ if (r.type === "font") {
536
+ resolvedResources.add(`font:${r.url}:`);
537
+ }
479
538
  const data = await cachedResolver({ ...r });
480
539
  if (!data)
481
540
  return;
@@ -502,7 +561,7 @@ export class SatoruBase {
502
561
  ? data
503
562
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
504
563
  if (r.type === "css") {
505
- const cssText = new TextDecoder().decode(finalUint8);
564
+ const cssText = utf8Decoder.decode(finalUint8);
506
565
  const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(r.url) ||
507
566
  r.url.startsWith("data:");
508
567
  let cssBaseUrl = r.url;
@@ -530,7 +589,7 @@ export class SatoruBase {
530
589
  return match;
531
590
  }
532
591
  });
533
- finalUint8 = new TextEncoder().encode(rewrittenCss);
592
+ finalUint8 = utf8Encoder.encode(rewrittenCss);
534
593
  }
535
594
  }
536
595
  await loadResourceData(r, finalUint8);
@@ -540,7 +599,9 @@ export class SatoruBase {
540
599
  console.warn(`Failed to resolve resource: ${r.url}`, e);
541
600
  }
542
601
  }));
602
+ addProfile("loadPendingResources", now() - loadStart);
543
603
  }
604
+ const stripStart = now();
544
605
  const resolvedUrls = new Set();
545
606
  resolvedResources.forEach((key) => {
546
607
  const parts = key.split(":");
@@ -554,6 +615,7 @@ export class SatoruBase {
554
615
  processedHtml = processedHtml.replace(linkRegex, "");
555
616
  });
556
617
  processedHtmls.push(processedHtml);
618
+ addProfile("stripResolvedLinks", now() - stripStart);
557
619
  }
558
620
  const formatMap = {
559
621
  svg: 0,
@@ -561,29 +623,55 @@ export class SatoruBase {
561
623
  webp: 2,
562
624
  pdf: 3,
563
625
  };
564
- const result = mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
565
- svgTextToPaths: options.textToPaths ?? true,
566
- outputWidth: options.outputWidth ?? 0,
567
- outputHeight: options.outputHeight ?? 0,
568
- fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
569
- cropX: options.crop?.x ?? 0,
570
- cropY: options.crop?.y ?? 0,
571
- cropWidth: options.crop?.width ?? 0,
572
- cropHeight: options.crop?.height ?? 0,
573
- fitPositionX: options.fitPosition?.x ?? 0.5,
574
- fitPositionY: options.fitPosition?.y ?? 0.5,
575
- backgroundColor: this.parseColor(options.backgroundColor),
576
- mediaType: options.mediaType === "print" ? 1 : 0,
577
- });
626
+ const renderStart = now();
627
+ const result = (processedHtmls.length === 1)
628
+ ? mod.render_from_state(instancePtr, width, height, formatMap[format] ?? 0, {
629
+ svgTextToPaths: options.textToPaths ?? true,
630
+ outputWidth: options.outputWidth ?? 0,
631
+ outputHeight: options.outputHeight ?? 0,
632
+ fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
633
+ cropX: options.crop?.x ?? 0,
634
+ cropY: options.crop?.y ?? 0,
635
+ cropWidth: options.crop?.width ?? 0,
636
+ cropHeight: options.crop?.height ?? 0,
637
+ fitPositionX: options.fitPosition?.x ?? 0.5,
638
+ fitPositionY: options.fitPosition?.y ?? 0.5,
639
+ backgroundColor: this.parseColor(options.backgroundColor),
640
+ mediaType: options.mediaType === "print" ? 1 : 0,
641
+ })
642
+ : mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
643
+ svgTextToPaths: options.textToPaths ?? true,
644
+ outputWidth: options.outputWidth ?? 0,
645
+ outputHeight: options.outputHeight ?? 0,
646
+ fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
647
+ cropX: options.crop?.x ?? 0,
648
+ cropY: options.crop?.y ?? 0,
649
+ cropWidth: options.crop?.width ?? 0,
650
+ cropHeight: options.crop?.height ?? 0,
651
+ fitPositionX: options.fitPosition?.x ?? 0.5,
652
+ fitPositionY: options.fitPosition?.y ?? 0.5,
653
+ backgroundColor: this.parseColor(options.backgroundColor),
654
+ mediaType: options.mediaType === "print" ? 1 : 0,
655
+ });
656
+ addProfile("wasmRender", now() - renderStart);
578
657
  if (!result) {
658
+ options.onProfile?.(profile);
579
659
  if (format === "svg")
580
660
  return "";
581
661
  return new Uint8Array();
582
662
  }
583
663
  if (format === "svg") {
584
- return new TextDecoder().decode(result);
664
+ const decodeStart = now();
665
+ const svg = utf8Decoder.decode(result);
666
+ addProfile("decodeResult", now() - decodeStart);
667
+ options.onProfile?.(profile);
668
+ return svg;
585
669
  }
586
- return new Uint8Array(result);
670
+ const copyStart = now();
671
+ const bytes = new Uint8Array(result);
672
+ addProfile("copyResult", now() - copyStart);
673
+ options.onProfile?.(profile);
674
+ return bytes;
587
675
  }
588
676
  finally {
589
677
  mod.destroy_instance(instancePtr);
Binary file