satoru-render 0.0.24 → 1.0.0

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.
@@ -259,6 +259,59 @@ const DEFAULT_FONT_MAP = {
259
259
  emoji: "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
260
260
  "Noto Color Emoji": "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2"
261
261
  };
262
+ /**
263
+ * Parse unicode-range string into an array of [start, end] codepoint ranges.
264
+ * e.g. "U+0000-00FF, U+0131" → [[0x0000, 0x00FF], [0x0131, 0x0131]]
265
+ */
266
+ function parseUnicodeRanges(rangeStr) {
267
+ const ranges = [];
268
+ for (const part of rangeStr.split(",")) {
269
+ const m = part.trim().match(/U\+([0-9A-Fa-f?]+)(?:-([0-9A-Fa-f]+))?/);
270
+ if (!m) continue;
271
+ if (m[1].includes("?")) {
272
+ const lo = parseInt(m[1].replace(/\?/g, "0"), 16);
273
+ const hi = parseInt(m[1].replace(/\?/g, "F"), 16);
274
+ ranges.push([lo, hi]);
275
+ } else {
276
+ const start = parseInt(m[1], 16);
277
+ const end = m[2] ? parseInt(m[2], 16) : start;
278
+ ranges.push([start, end]);
279
+ }
280
+ }
281
+ return ranges;
282
+ }
283
+ /**
284
+ * Check if any character's codepoint falls within the given unicode ranges.
285
+ */
286
+ function hasMatchingCodepoint(ranges, characters) {
287
+ for (let i = 0; i < characters.length; i++) {
288
+ const cp = characters.codePointAt(i);
289
+ if (cp > 65535) i++;
290
+ for (const [start, end] of ranges) if (cp >= start && cp <= end) return true;
291
+ }
292
+ return false;
293
+ }
294
+ /**
295
+ * Parse @font-face blocks from CSS text, extracting src url and unicode-range.
296
+ */
297
+ function parseFontFaceBlocks(cssText) {
298
+ const blocks = [];
299
+ const blockRegex = /@font-face\s*\{([^}]+)\}/g;
300
+ let blockMatch;
301
+ while ((blockMatch = blockRegex.exec(cssText)) !== null) {
302
+ const body = blockMatch[1];
303
+ const srcMatch = body.match(/src:\s*url\(([^)]+)\)/);
304
+ const rangeMatch = body.match(/unicode-range:\s*([^;]+)/);
305
+ if (srcMatch) {
306
+ const url = srcMatch[1].replace(/['"]/g, "").trim();
307
+ blocks.push({
308
+ url,
309
+ unicodeRange: rangeMatch ? rangeMatch[1].trim() : null
310
+ });
311
+ }
312
+ }
313
+ return blocks;
314
+ }
262
315
  async function resolveGoogleFonts(resource, userAgent) {
263
316
  if (!resource.url.startsWith("provider:google-fonts")) return null;
264
317
  const urlObj = new URL(resource.url);
@@ -270,8 +323,7 @@ async function resolveGoogleFonts(resource, userAgent) {
270
323
  let targetFamily = family;
271
324
  let forceNormalStyle = false;
272
325
  if (targetFamily.includes("Noto Sans JP") || targetFamily.includes("Noto Serif JP") || targetFamily.includes("CJK")) forceNormalStyle = true;
273
- const useItalic = italic && !forceNormalStyle;
274
- let googleFontUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(targetFamily)}:ital,wght@${useItalic ? "1" : "0"},${weight}&display=swap`;
326
+ let googleFontUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(targetFamily)}:ital,wght@${italic && !forceNormalStyle ? "1" : "0"},${weight}&display=swap`;
275
327
  if (text) {
276
328
  if (text.length < 800) googleFontUrl += `&text=${encodeURIComponent(text)}`;
277
329
  }
@@ -280,8 +332,35 @@ async function resolveGoogleFonts(resource, userAgent) {
280
332
  try {
281
333
  const resp = await fetch(googleFontUrl, { headers });
282
334
  if (!resp.ok) return null;
283
- const buf = await resp.arrayBuffer();
284
- return new Uint8Array(buf);
335
+ const cssText = await resp.text();
336
+ const cssBuf = new TextEncoder().encode(cssText);
337
+ const blocks = parseFontFaceBlocks(cssText);
338
+ if (blocks.length === 0) return cssBuf;
339
+ let filteredBlocks = blocks;
340
+ if (text && text.length > 0) filteredBlocks = blocks.filter((block) => {
341
+ if (!block.unicodeRange) return true;
342
+ return hasMatchingCodepoint(parseUnicodeRanges(block.unicodeRange), text);
343
+ });
344
+ if (filteredBlocks.length === 0) return cssBuf;
345
+ const fontResults = await Promise.all(filteredBlocks.map(async (block) => {
346
+ try {
347
+ const fontResp = await fetch(block.url, { headers });
348
+ if (!fontResp.ok) return null;
349
+ const buf = await fontResp.arrayBuffer();
350
+ return {
351
+ url: block.url,
352
+ data: new Uint8Array(buf)
353
+ };
354
+ } catch {
355
+ return null;
356
+ }
357
+ }));
358
+ const fonts = [];
359
+ for (const f of fontResults) if (f !== null) fonts.push(f);
360
+ return {
361
+ css: cssBuf,
362
+ fonts
363
+ };
285
364
  } catch {
286
365
  return null;
287
366
  }
@@ -290,6 +369,7 @@ var SatoruBase = class {
290
369
  factory;
291
370
  modPromise;
292
371
  currentFontMap = DEFAULT_FONT_MAP;
372
+ resourceCache = /* @__PURE__ */ new Map();
293
373
  constructor(factory) {
294
374
  this.factory = factory;
295
375
  }
@@ -351,7 +431,16 @@ var SatoruBase = class {
351
431
  webp: 2,
352
432
  pdf: 3
353
433
  }[options.format ?? "svg"] ?? 0;
354
- const result = mod.render_from_state(inst, options.width, options.height ?? 0, format, options.textToPaths ?? true);
434
+ const result = mod.render_from_state(inst, options.width, options.height ?? 0, format, {
435
+ svgTextToPaths: options.textToPaths ?? true,
436
+ outputWidth: options.outputWidth ?? 0,
437
+ outputHeight: options.outputHeight ?? 0,
438
+ fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
439
+ cropX: options.crop?.x ?? 0,
440
+ cropY: options.crop?.y ?? 0,
441
+ cropWidth: options.crop?.width ?? 0,
442
+ cropHeight: options.crop?.height ?? 0
443
+ });
355
444
  if (!result) {
356
445
  if (options.format === "svg") return "";
357
446
  return new Uint8Array();
@@ -389,12 +478,12 @@ var SatoruBase = class {
389
478
  try {
390
479
  const result = mod.merge_pdfs(instancePtr, pagePdfs);
391
480
  if (!result) return new Uint8Array();
392
- return new Uint8Array(result.slice());
481
+ return new Uint8Array(result);
393
482
  } finally {
394
483
  mod.destroy_instance(instancePtr);
395
484
  }
396
485
  }
397
- const mod = await this.getModule();
486
+ let mod = await this.getModule();
398
487
  const { width, height = 0, fonts, images, css, logLevel, onLog } = options;
399
488
  if (!options.userAgent) options.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
400
489
  if (url && !value) {
@@ -420,6 +509,40 @@ var SatoruBase = class {
420
509
  const resolver = options.resolveResource ? async (r) => {
421
510
  return await options.resolveResource(r, defaultResolver);
422
511
  } : defaultResolver;
512
+ const cachedResolver = async (r) => {
513
+ const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
514
+ const cached = this.resourceCache.get(cacheKey);
515
+ if (cached) return cached;
516
+ const result = await resolver(r);
517
+ if (result) {
518
+ if (result instanceof Uint8Array) this.resourceCache.set(cacheKey, result);
519
+ else if ("css" in result && "fonts" in result) this.resourceCache.set(cacheKey, result);
520
+ }
521
+ return result;
522
+ };
523
+ const loadResourceData = (r, uint8) => {
524
+ if (r.type === "image" && typeof createImageBitmap !== "undefined" && typeof OffscreenCanvas !== "undefined") return (async () => {
525
+ try {
526
+ const blob = new Blob([uint8.buffer]);
527
+ const bitmap = await createImageBitmap(blob);
528
+ const ctx = new OffscreenCanvas(bitmap.width, bitmap.height).getContext("2d");
529
+ if (ctx) {
530
+ ctx.drawImage(bitmap, 0, 0);
531
+ const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
532
+ mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
533
+ return;
534
+ }
535
+ } catch (e) {}
536
+ let typeInt = 1;
537
+ if (r.type === "image") typeInt = 2;
538
+ if (r.type === "css") typeInt = 3;
539
+ mod.add_resource(instancePtr, r.url, typeInt, uint8);
540
+ })();
541
+ let typeInt = 1;
542
+ if (r.type === "image") typeInt = 2;
543
+ if (r.type === "css") typeInt = 3;
544
+ mod.add_resource(instancePtr, r.url, typeInt, uint8);
545
+ };
423
546
  const inputHtmls = Array.isArray(value) ? value : [value];
424
547
  const processedHtmls = [];
425
548
  const resolvedResources = /* @__PURE__ */ new Set();
@@ -439,25 +562,19 @@ var SatoruBase = class {
439
562
  if (r.url.startsWith("data:")) return;
440
563
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
441
564
  resolvedResources.add(key);
442
- const data = await resolver({ ...r });
443
- if (data && (data instanceof Uint8Array || ArrayBuffer.isView(data))) {
444
- const uint8 = data instanceof Uint8Array ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
445
- if (r.type === "image" && typeof createImageBitmap !== "undefined" && typeof OffscreenCanvas !== "undefined") try {
446
- const blob = new Blob([uint8.buffer]);
447
- const bitmap = await createImageBitmap(blob);
448
- const ctx = new OffscreenCanvas(bitmap.width, bitmap.height).getContext("2d");
449
- if (ctx) {
450
- ctx.drawImage(bitmap, 0, 0);
451
- const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
452
- mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
453
- return;
454
- }
455
- } catch (e) {}
456
- let typeInt = 1;
457
- if (r.type === "image") typeInt = 2;
458
- if (r.type === "css") typeInt = 3;
459
- mod.add_resource(instancePtr, r.url, typeInt, uint8);
565
+ const data = await cachedResolver({ ...r });
566
+ if (!data) return;
567
+ if (typeof data === "object" && "css" in data && "fonts" in data) {
568
+ const fontResult = data;
569
+ mod.add_resource(instancePtr, r.url, 1, fontResult.css);
570
+ for (const font of fontResult.fonts) {
571
+ const fontKey = `font:${font.url}:`;
572
+ resolvedResources.add(fontKey);
573
+ mod.add_resource(instancePtr, font.url, 1, font.data);
574
+ }
575
+ return;
460
576
  }
577
+ if (data instanceof Uint8Array || ArrayBuffer.isView(data)) await loadResourceData(r, data instanceof Uint8Array ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
461
578
  } catch (e) {
462
579
  console.warn(`Failed to resolve resource: ${r.url}`, e);
463
580
  }
@@ -480,13 +597,22 @@ var SatoruBase = class {
480
597
  png: 1,
481
598
  webp: 2,
482
599
  pdf: 3
483
- }[format] ?? 0, options.textToPaths ?? true);
600
+ }[format] ?? 0, {
601
+ svgTextToPaths: options.textToPaths ?? true,
602
+ outputWidth: options.outputWidth ?? 0,
603
+ outputHeight: options.outputHeight ?? 0,
604
+ fitType: options.fit === "cover" ? 1 : options.fit === "fill" ? 2 : 0,
605
+ cropX: options.crop?.x ?? 0,
606
+ cropY: options.crop?.y ?? 0,
607
+ cropWidth: options.crop?.width ?? 0,
608
+ cropHeight: options.crop?.height ?? 0
609
+ });
484
610
  if (!result) {
485
611
  if (format === "svg") return "";
486
612
  return new Uint8Array();
487
613
  }
488
614
  if (format === "svg") return new TextDecoder().decode(result);
489
- return new Uint8Array(result.slice());
615
+ return new Uint8Array(result);
490
616
  } finally {
491
617
  mod.destroy_instance(instancePtr);
492
618
  mod.logLevel = prevLogLevel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "satoru-render",
3
- "version": "0.0.24",
3
+ "version": "1.0.0",
4
4
  "description": "High-fidelity HTML/CSS to SVG/PNG/PDF converter running in WebAssembly",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -122,10 +122,10 @@
122
122
  "cloudflare-workers"
123
123
  ],
124
124
  "devDependencies": {
125
- "@types/jsdom": "28.0.0",
125
+ "@types/jsdom": "28.0.1",
126
126
  "@types/react": "^19.2.14",
127
127
  "@types/react-dom": "^19.2.3",
128
- "rolldown": "1.0.0-rc.9",
128
+ "rolldown": "1.0.0-rc.11",
129
129
  "typescript": "^5.9.3",
130
130
  "vitest": "^4.1.0"
131
131
  },