satoru-render 0.0.25 → 1.0.1

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/README.md CHANGED
@@ -352,6 +352,10 @@ npx satoru-render input.html -f webp --verbose
352
352
  | `url` | `string` | URL to fetch HTML from. |
353
353
  | `width` | `number` | **Required.** Output width in pixels. |
354
354
  | `height` | `number` | Output height. Default: `0` (auto-calculate). |
355
+ | `crop` | `{ x, y, width, height }` | Crop parameters to extract a specific region. |
356
+ | `outputWidth` | `number` | Target output width when scaling the image. |
357
+ | `outputHeight` | `number` | Target output height when scaling the image. |
358
+ | `fit` | `"contain" \| "cover" \| "fill"` | Resizing behavior when output scale differs. |
355
359
  | `format` | `"svg" \| "png" \| "webp" \| "pdf"` | Output format. Default: `"svg"`. |
356
360
  | `resolveResource` | `ResourceResolver` | Async callback to fetch assets (fonts, images, CSS). |
357
361
  | `fonts` | `Object[]` | Pre-load fonts: `[{ name, data: Uint8Array }]`. |
package/dist/core.d.ts CHANGED
@@ -14,8 +14,8 @@ export interface SatoruModule {
14
14
  set_log_level: (level: number) => void;
15
15
  init_document: (inst: any, html: string, width: number, height: number) => void;
16
16
  layout_document: (inst: any, width: number) => void;
17
- render_from_state: (inst: any, width: number, height: number, format: number, svgTextToPaths: boolean) => Uint8Array | null;
18
- render: (inst: any, htmls: string | string[], width: number, height: number, format: number, svgTextToPaths: boolean) => Uint8Array | null;
17
+ render_from_state: (inst: any, width: number, height: number, format: number, options: any) => Uint8Array | null;
18
+ render: (inst: any, htmls: string | string[], width: number, height: number, format: number, options: any) => Uint8Array | null;
19
19
  merge_pdfs: (inst: any, pdfs: Uint8Array[]) => Uint8Array | null;
20
20
  onLog?: (level: LogLevel, message: string) => void;
21
21
  logLevel: LogLevel;
@@ -27,12 +27,28 @@ export interface RequiredResource {
27
27
  characters?: string;
28
28
  redraw_on_ready?: boolean;
29
29
  }
30
- export type ResourceResolver = (resource: RequiredResource, defaultResolver: (resource: RequiredResource) => Promise<Uint8Array | null>) => Promise<Uint8Array | ArrayBufferView | null>;
30
+ export interface ResolvedFontResult {
31
+ css: Uint8Array;
32
+ fonts: {
33
+ url: string;
34
+ data: Uint8Array;
35
+ }[];
36
+ }
37
+ export type ResourceResolver = (resource: RequiredResource, defaultResolver: (resource: RequiredResource) => Promise<Uint8Array | ResolvedFontResult | null>) => Promise<Uint8Array | ArrayBufferView | ResolvedFontResult | null>;
31
38
  export interface RenderOptions {
32
39
  value?: string | string[] | any | any[];
33
40
  url?: string;
34
41
  width: number;
35
42
  height?: number;
43
+ crop?: {
44
+ x: number;
45
+ y: number;
46
+ width: number;
47
+ height: number;
48
+ };
49
+ outputWidth?: number;
50
+ outputHeight?: number;
51
+ fit?: "contain" | "cover" | "fill";
36
52
  format?: "svg" | "png" | "webp" | "pdf";
37
53
  textToPaths?: boolean;
38
54
  resolveResource?: ResourceResolver;
@@ -55,11 +71,12 @@ export interface RenderOptions {
55
71
  onLog?: (level: LogLevel, message: string) => void;
56
72
  }
57
73
  export declare const DEFAULT_FONT_MAP: Record<string, string>;
58
- export declare function resolveGoogleFonts(resource: RequiredResource, userAgent?: string): Promise<Uint8Array | null>;
74
+ export declare function resolveGoogleFonts(resource: RequiredResource, userAgent?: string): Promise<ResolvedFontResult | Uint8Array | null>;
59
75
  export declare abstract class SatoruBase {
60
76
  private factory;
61
77
  private modPromise?;
62
78
  protected currentFontMap: Record<string, string>;
79
+ private resourceCache;
63
80
  protected constructor(factory: any);
64
81
  private getModule;
65
82
  initDocument(options: Omit<RenderOptions, "value"> & {
@@ -69,12 +86,21 @@ export declare abstract class SatoruBase {
69
86
  renderFromState(inst: any, options: {
70
87
  width: number;
71
88
  height?: number;
89
+ crop?: {
90
+ x: number;
91
+ y: number;
92
+ width: number;
93
+ height: number;
94
+ };
95
+ outputWidth?: number;
96
+ outputHeight?: number;
97
+ fit?: "contain" | "cover" | "fill";
72
98
  format?: "svg" | "png" | "webp" | "pdf";
73
99
  textToPaths?: boolean;
74
100
  }): Promise<string | Uint8Array>;
75
101
  destroyInstance(inst: any): Promise<void>;
76
102
  loadFallbackFont(data: Uint8Array): Promise<void>;
77
- protected abstract resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
103
+ protected abstract resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | ResolvedFontResult | null>;
78
104
  protected abstract fetchHtml(url: string, userAgent?: string): Promise<string>;
79
105
  render(options: RenderOptions & {
80
106
  format: "png" | "webp" | "pdf";
package/dist/core.js CHANGED
@@ -8,6 +8,66 @@ export const DEFAULT_FONT_MAP = {
8
8
  emoji: "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
9
9
  "Noto Color Emoji": "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
10
10
  };
11
+ /**
12
+ * Parse unicode-range string into an array of [start, end] codepoint ranges.
13
+ * e.g. "U+0000-00FF, U+0131" → [[0x0000, 0x00FF], [0x0131, 0x0131]]
14
+ */
15
+ function parseUnicodeRanges(rangeStr) {
16
+ const ranges = [];
17
+ for (const part of rangeStr.split(",")) {
18
+ const m = part.trim().match(/U\+([0-9A-Fa-f?]+)(?:-([0-9A-Fa-f]+))?/);
19
+ if (!m)
20
+ continue;
21
+ if (m[1].includes("?")) {
22
+ // Wildcard range: U+4?? means U+400-U+4FF
23
+ const lo = parseInt(m[1].replace(/\?/g, "0"), 16);
24
+ const hi = parseInt(m[1].replace(/\?/g, "F"), 16);
25
+ ranges.push([lo, hi]);
26
+ }
27
+ else {
28
+ const start = parseInt(m[1], 16);
29
+ const end = m[2] ? parseInt(m[2], 16) : start;
30
+ ranges.push([start, end]);
31
+ }
32
+ }
33
+ return ranges;
34
+ }
35
+ /**
36
+ * Check if any character's codepoint falls within the given unicode ranges.
37
+ */
38
+ function hasMatchingCodepoint(ranges, characters) {
39
+ for (let i = 0; i < characters.length; i++) {
40
+ const cp = characters.codePointAt(i);
41
+ if (cp > 0xffff)
42
+ i++; // skip surrogate pair
43
+ for (const [start, end] of ranges) {
44
+ if (cp >= start && cp <= end)
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ /**
51
+ * Parse @font-face blocks from CSS text, extracting src url and unicode-range.
52
+ */
53
+ function parseFontFaceBlocks(cssText) {
54
+ const blocks = [];
55
+ const blockRegex = /@font-face\s*\{([^}]+)\}/g;
56
+ let blockMatch;
57
+ while ((blockMatch = blockRegex.exec(cssText)) !== null) {
58
+ const body = blockMatch[1];
59
+ const srcMatch = body.match(/src:\s*url\(([^)]+)\)/);
60
+ const rangeMatch = body.match(/unicode-range:\s*([^;]+)/);
61
+ if (srcMatch) {
62
+ const url = srcMatch[1].replace(/['"]/g, "").trim();
63
+ blocks.push({
64
+ url,
65
+ unicodeRange: rangeMatch ? rangeMatch[1].trim() : null,
66
+ });
67
+ }
68
+ }
69
+ return blocks;
70
+ }
11
71
  export async function resolveGoogleFonts(resource, userAgent) {
12
72
  if (!resource.url.startsWith("provider:google-fonts"))
13
73
  return null;
@@ -40,8 +100,46 @@ export async function resolveGoogleFonts(resource, userAgent) {
40
100
  const resp = await fetch(googleFontUrl, { headers });
41
101
  if (!resp.ok)
42
102
  return null;
43
- const buf = await resp.arrayBuffer();
44
- return new Uint8Array(buf);
103
+ const cssText = await resp.text();
104
+ const cssBuf = new TextEncoder().encode(cssText);
105
+ // Parse @font-face blocks with their unicode-range
106
+ const blocks = parseFontFaceBlocks(cssText);
107
+ if (blocks.length === 0) {
108
+ return cssBuf;
109
+ }
110
+ // Filter blocks by unicode-range if characters are available
111
+ let filteredBlocks = blocks;
112
+ if (text && text.length > 0) {
113
+ filteredBlocks = blocks.filter((block) => {
114
+ if (!block.unicodeRange)
115
+ return true; // no range = always include
116
+ const ranges = parseUnicodeRanges(block.unicodeRange);
117
+ return hasMatchingCodepoint(ranges, text);
118
+ });
119
+ }
120
+ if (filteredBlocks.length === 0) {
121
+ // No matching subsets - still return CSS for C++ to parse
122
+ return cssBuf;
123
+ }
124
+ // Prefetch only the matching font binaries in parallel
125
+ const fontResults = await Promise.all(filteredBlocks.map(async (block) => {
126
+ try {
127
+ const fontResp = await fetch(block.url, { headers });
128
+ if (!fontResp.ok)
129
+ return null;
130
+ const buf = await fontResp.arrayBuffer();
131
+ return { url: block.url, data: new Uint8Array(buf) };
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }));
137
+ const fonts = [];
138
+ for (const f of fontResults) {
139
+ if (f !== null)
140
+ fonts.push(f);
141
+ }
142
+ return { css: cssBuf, fonts };
45
143
  }
46
144
  catch {
47
145
  return null;
@@ -51,6 +149,7 @@ export class SatoruBase {
51
149
  factory;
52
150
  modPromise;
53
151
  currentFontMap = DEFAULT_FONT_MAP;
152
+ resourceCache = new Map();
54
153
  constructor(factory) {
55
154
  this.factory = factory;
56
155
  }
@@ -129,7 +228,16 @@ export class SatoruBase {
129
228
  pdf: 3,
130
229
  };
131
230
  const format = formatMap[options.format ?? "svg"] ?? 0;
132
- const result = mod.render_from_state(inst, options.width, options.height ?? 0, format, options.textToPaths ?? true);
231
+ const result = mod.render_from_state(inst, options.width, options.height ?? 0, format, {
232
+ svgTextToPaths: options.textToPaths ?? true,
233
+ outputWidth: options.outputWidth ?? 0,
234
+ outputHeight: options.outputHeight ?? 0,
235
+ fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
236
+ cropX: options.crop?.x ?? 0,
237
+ cropY: options.crop?.y ?? 0,
238
+ cropWidth: options.crop?.width ?? 0,
239
+ cropHeight: options.crop?.height ?? 0,
240
+ });
133
241
  if (!result) {
134
242
  if (options.format === "svg")
135
243
  return "";
@@ -229,6 +337,58 @@ export class SatoruBase {
229
337
  return await options.resolveResource(r, defaultResolver);
230
338
  }
231
339
  : defaultResolver;
340
+ const cachedResolver = async (r) => {
341
+ const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
342
+ const cached = this.resourceCache.get(cacheKey);
343
+ if (cached)
344
+ return cached;
345
+ const result = await resolver(r);
346
+ if (result) {
347
+ if (result instanceof Uint8Array) {
348
+ this.resourceCache.set(cacheKey, result);
349
+ }
350
+ else if ("css" in result &&
351
+ "fonts" in result) {
352
+ this.resourceCache.set(cacheKey, result);
353
+ }
354
+ }
355
+ return result;
356
+ };
357
+ const loadResourceData = (r, uint8) => {
358
+ if (r.type === "image" &&
359
+ typeof createImageBitmap !== "undefined" &&
360
+ typeof OffscreenCanvas !== "undefined") {
361
+ return (async () => {
362
+ try {
363
+ const blob = new Blob([uint8.buffer]);
364
+ const bitmap = await createImageBitmap(blob);
365
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
366
+ const ctx = canvas.getContext("2d");
367
+ if (ctx) {
368
+ ctx.drawImage(bitmap, 0, 0);
369
+ const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
370
+ mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
371
+ return;
372
+ }
373
+ }
374
+ catch (e) {
375
+ // fall through
376
+ }
377
+ let typeInt = 1;
378
+ if (r.type === "image")
379
+ typeInt = 2;
380
+ if (r.type === "css")
381
+ typeInt = 3;
382
+ mod.add_resource(instancePtr, r.url, typeInt, uint8);
383
+ })();
384
+ }
385
+ let typeInt = 1;
386
+ if (r.type === "image")
387
+ typeInt = 2;
388
+ if (r.type === "css")
389
+ typeInt = 3;
390
+ mod.add_resource(instancePtr, r.url, typeInt, uint8);
391
+ };
232
392
  const inputHtmls = Array.isArray(value) ? value : [value];
233
393
  const processedHtmls = [];
234
394
  const resolvedResources = new Set();
@@ -253,36 +413,33 @@ export class SatoruBase {
253
413
  }
254
414
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
255
415
  resolvedResources.add(key);
256
- const data = await resolver({ ...r });
257
- if (data &&
258
- (data instanceof Uint8Array || ArrayBuffer.isView(data))) {
416
+ const data = await cachedResolver({ ...r });
417
+ if (!data)
418
+ return;
419
+ // Handle ResolvedFontResult (CSS + prefetched font binaries)
420
+ if (typeof data === "object" &&
421
+ "css" in data &&
422
+ "fonts" in data) {
423
+ const fontResult = data;
424
+ // Load the CSS first so C++ can parse @font-face
425
+ mod.add_resource(instancePtr, r.url, 1, // Font type
426
+ fontResult.css);
427
+ // Then load all prefetched font binaries directly
428
+ for (const font of fontResult.fonts) {
429
+ const fontKey = `font:${font.url}:`;
430
+ resolvedResources.add(fontKey);
431
+ mod.add_resource(instancePtr, font.url, 1, // Font type
432
+ font.data);
433
+ }
434
+ return;
435
+ }
436
+ // Handle regular Uint8Array / ArrayBufferView
437
+ if (data instanceof Uint8Array ||
438
+ ArrayBuffer.isView(data)) {
259
439
  const uint8 = data instanceof Uint8Array
260
440
  ? data
261
441
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
262
- if (r.type === "image" &&
263
- typeof createImageBitmap !== "undefined" &&
264
- typeof OffscreenCanvas !== "undefined") {
265
- try {
266
- const blob = new Blob([uint8.buffer]);
267
- const bitmap = await createImageBitmap(blob);
268
- const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
269
- const ctx = canvas.getContext("2d");
270
- if (ctx) {
271
- ctx.drawImage(bitmap, 0, 0);
272
- const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
273
- mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
274
- return;
275
- }
276
- }
277
- catch (e) {
278
- }
279
- }
280
- let typeInt = 1;
281
- if (r.type === "image")
282
- typeInt = 2;
283
- if (r.type === "css")
284
- typeInt = 3;
285
- mod.add_resource(instancePtr, r.url, typeInt, uint8);
442
+ await loadResourceData(r, uint8);
286
443
  }
287
444
  }
288
445
  catch (e) {
@@ -310,7 +467,16 @@ export class SatoruBase {
310
467
  webp: 2,
311
468
  pdf: 3,
312
469
  };
313
- const result = mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, options.textToPaths ?? true);
470
+ const result = mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
471
+ svgTextToPaths: options.textToPaths ?? true,
472
+ outputWidth: options.outputWidth ?? 0,
473
+ outputHeight: options.outputHeight ?? 0,
474
+ fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
475
+ cropX: options.crop?.x ?? 0,
476
+ cropY: options.crop?.y ?? 0,
477
+ cropWidth: options.crop?.width ?? 0,
478
+ cropHeight: options.crop?.height ?? 0,
479
+ });
314
480
  if (!result) {
315
481
  if (format === "svg")
316
482
  return "";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SatoruBase, RequiredResource, RenderOptions } from "./core.js";
1
+ import { SatoruBase, RequiredResource, ResolvedFontResult, RenderOptions } from "./core.js";
2
2
  export * from "./core.js";
3
3
  export * from "./log-level.js";
4
4
  export type { SatoruWorker } from "./child-workers.js";
@@ -21,7 +21,7 @@ export declare class Satoru extends SatoruBase {
21
21
  format?: "svg";
22
22
  }): Promise<string>;
23
23
  render(options: RenderOptions): Promise<string | Uint8Array>;
24
- static defaultResourceResolver(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
25
- protected resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
24
+ static defaultResourceResolver(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | ResolvedFontResult | null>;
25
+ protected resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | ResolvedFontResult | null>;
26
26
  protected fetchHtml(url: string, userAgent?: string): Promise<string>;
27
27
  }
package/dist/node.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { SatoruBase, RequiredResource } from "./core.js";
1
+ import { SatoruBase, RequiredResource, ResolvedFontResult } from "./core.js";
2
2
  export * from "./core.js";
3
3
  export * from "./log-level.js";
4
4
  export type { SatoruWorker } from "./child-workers.js";
5
5
  export declare class Satoru extends SatoruBase {
6
6
  static create(createSatoruModuleFunc: any): Promise<Satoru>;
7
- static defaultResourceResolver(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
8
- protected resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
7
+ static defaultResourceResolver(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | ResolvedFontResult | null>;
8
+ protected resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | ResolvedFontResult | null>;
9
9
  protected fetchHtml(url: string, userAgent?: string): Promise<string>;
10
10
  }
Binary file