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.
- package/LICENSE +1 -1
- package/README.md +4 -0
- package/dist/core.d.ts +31 -5
- package/dist/core.js +200 -34
- package/dist/index.d.ts +3 -3
- package/dist/node.d.ts +3 -3
- package/dist/satoru-single.js +0 -0
- package/dist/satoru.js +1 -1
- package/dist/satoru.wasm +0 -0
- package/dist/web-workers.js +240 -114
- package/dist/workers-parent.js +153 -27
- package/package.json +3 -3
package/dist/workers-parent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
284
|
-
|
|
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,
|
|
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
|
|
481
|
+
return new Uint8Array(result);
|
|
393
482
|
} finally {
|
|
394
483
|
mod.destroy_instance(instancePtr);
|
|
395
484
|
}
|
|
396
485
|
}
|
|
397
|
-
|
|
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
|
|
443
|
-
if (data
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
128
|
+
"rolldown": "1.0.0-rc.11",
|
|
129
129
|
"typescript": "^5.9.3",
|
|
130
130
|
"vitest": "^4.1.0"
|
|
131
131
|
},
|