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.
@@ -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);
@@ -279,8 +332,35 @@ async function resolveGoogleFonts(resource, userAgent) {
279
332
  try {
280
333
  const resp = await fetch(googleFontUrl, { headers });
281
334
  if (!resp.ok) return null;
282
- const buf = await resp.arrayBuffer();
283
- 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
+ };
284
364
  } catch {
285
365
  return null;
286
366
  }
@@ -289,6 +369,7 @@ var SatoruBase = class {
289
369
  factory;
290
370
  modPromise;
291
371
  currentFontMap = DEFAULT_FONT_MAP;
372
+ resourceCache = /* @__PURE__ */ new Map();
292
373
  constructor(factory) {
293
374
  this.factory = factory;
294
375
  }
@@ -350,7 +431,16 @@ var SatoruBase = class {
350
431
  webp: 2,
351
432
  pdf: 3
352
433
  }[options.format ?? "svg"] ?? 0;
353
- 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
+ });
354
444
  if (!result) {
355
445
  if (options.format === "svg") return "";
356
446
  return new Uint8Array();
@@ -419,6 +509,40 @@ var SatoruBase = class {
419
509
  const resolver = options.resolveResource ? async (r) => {
420
510
  return await options.resolveResource(r, defaultResolver);
421
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
+ };
422
546
  const inputHtmls = Array.isArray(value) ? value : [value];
423
547
  const processedHtmls = [];
424
548
  const resolvedResources = /* @__PURE__ */ new Set();
@@ -438,25 +562,19 @@ var SatoruBase = class {
438
562
  if (r.url.startsWith("data:")) return;
439
563
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
440
564
  resolvedResources.add(key);
441
- const data = await resolver({ ...r });
442
- if (data && (data instanceof Uint8Array || ArrayBuffer.isView(data))) {
443
- const uint8 = data instanceof Uint8Array ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
444
- if (r.type === "image" && typeof createImageBitmap !== "undefined" && typeof OffscreenCanvas !== "undefined") try {
445
- const blob = new Blob([uint8.buffer]);
446
- const bitmap = await createImageBitmap(blob);
447
- const ctx = new OffscreenCanvas(bitmap.width, bitmap.height).getContext("2d");
448
- if (ctx) {
449
- ctx.drawImage(bitmap, 0, 0);
450
- const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
451
- mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
452
- return;
453
- }
454
- } catch (e) {}
455
- let typeInt = 1;
456
- if (r.type === "image") typeInt = 2;
457
- if (r.type === "css") typeInt = 3;
458
- 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;
459
576
  }
577
+ if (data instanceof Uint8Array || ArrayBuffer.isView(data)) await loadResourceData(r, data instanceof Uint8Array ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
460
578
  } catch (e) {
461
579
  console.warn(`Failed to resolve resource: ${r.url}`, e);
462
580
  }
@@ -479,7 +597,16 @@ var SatoruBase = class {
479
597
  png: 1,
480
598
  webp: 2,
481
599
  pdf: 3
482
- }[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
+ });
483
610
  if (!result) {
484
611
  if (format === "svg") return "";
485
612
  return new Uint8Array();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "satoru-render",
3
- "version": "0.0.25",
3
+ "version": "1.0.1",
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",