Skip to content

Commit 4976916

Browse files
committed
fix font export with wunderbaum
1 parent 46dec8e commit 4976916

5 files changed

Lines changed: 269 additions & 18 deletions

File tree

esbuild.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const statics = [
6363
['manifest.json', 'manifest.json'],
6464
['src/popup/popup.html', 'popup.html'],
6565
['src/popup/popup.css', 'popup.css'],
66+
['node_modules/fonteditor-core/woff2/woff2.wasm', 'woff2.wasm'],
6667
];
6768

6869
for (const [src, dest] of statics) {

manifest.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"manifest_version": 3,
33
"name": "Web2Vector",
4-
"description": "Export any web page to vector formats (SVG, DXF, DWG, PDF & EMF/EMF+) or raster formats (PNG, JPEG, WebP)",
5-
"version": "1.2.0",
4+
"description": "Export any web page to vector formats (SVG, DXF, DWG, PDF) or raster formats (PNG, JPEG, WebP)",
5+
"version": "1.4.0",
66
"icons": {
77
"16": "icons/icon16.png",
88
"32": "icons/icon32.png",
@@ -29,5 +29,15 @@
2929
],
3030
"background": {
3131
"service_worker": "service-worker.js"
32-
}
32+
},
33+
"web_accessible_resources": [
34+
{
35+
"resources": [
36+
"woff2.wasm"
37+
],
38+
"matches": [
39+
"<all_urls>"
40+
]
41+
}
42+
]
3343
}

package-lock.json

Lines changed: 15 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "web2vector-extension",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"description": "Export web pages as high-quality vector and image files directly from your browser. This extension uses layout2vector to convert page layouts into formats such as SVG, DXF, DWG, PDF and PNG, making it easy to reuse designs, archive content, or process graphics.",
55
"private": true,
66
"type": "module",
@@ -10,16 +10,17 @@
1010
"build:all": "npm run build:icons && npm run build",
1111
"test": "vitest run",
1212
"test:watch": "vitest",
13-
"package": "npm run build:all && node scripts/package-extension.mjs chrome",
13+
"package:chrome": "npm run build:all && node scripts/package-extension.mjs chrome",
1414
"package:firefox": "npm run build:all && node scripts/package-extension.mjs firefox",
15-
"package:all-browsers": "npm run build:all && node scripts/package-extension.mjs chrome && node scripts/package-extension.mjs firefox",
15+
"package": "npm run build:all && node scripts/package-extension.mjs chrome && node scripts/package-extension.mjs firefox",
1616
"upload": "node scripts/upload-to-store.mjs"
1717
},
1818
"dependencies": {
1919
"@chenglou/pretext": "^0.0.6",
20-
"@node-projects/layout2vector": "^5.21.0",
20+
"@node-projects/layout2vector": "^5.23.0",
2121
"fonteditor-core": "^2.6.3",
22-
"get-box-quads-polyfill": "^4.36.0"
22+
"get-box-quads-polyfill": "^4.37.0",
23+
"pako": "^2.1.0"
2324
},
2425
"devDependencies": {
2526
"@resvg/resvg-js": "^2.6.2",

src/content/run-export.js

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* triggers chrome.downloads.download({ saveAs: true }).
88
*/
99
import { calculateExportSize } from './export-size.js';
10+
import pako from 'pako';
1011
import { extensionApi } from '../shared/extension-api.js';
1112
import { EXPORT_STREAM_CHUNK_BYTES, createExportTransferId } from '../shared/export-transfer.js';
1213
import { normalizeTransferredFontAssets } from '../shared/font-assets.js';
@@ -20,6 +21,11 @@ import {
2021

2122
const PX_TO_MM = 25.4 / 96;
2223
const FONT_ASSET_FORMATS = new Set(['html', 'svg', 'pdf']);
24+
const PDF_FONT_SOURCE_PRIORITY = ['ttf', 'otf', 'woff', 'woff2'];
25+
const PDF_CONVERTIBLE_FONT_SOURCE_FORMATS = new Set(['otf', 'woff', 'woff2']);
26+
27+
let fontEditorCorePromise = null;
28+
let woff2InitPromise = null;
2329

2430
(async () => {
2531
try {
@@ -181,11 +187,14 @@ const FONT_ASSET_FORMATS = new Set(['html', 'svg', 'pdf']);
181187

182188
/* ── Document ── */
183189
case 'pdf': {
190+
const pdfFontAssets = await preparePdfFontAssets(fontAssets, exportOptions.pdfUseFontEditorCore);
191+
184192
const w = new writers.PDFWriter({
185193
pageWidth: width * PX_TO_MM,
186194
pageHeight: height * PX_TO_MM,
187-
fontAssets,
188-
useFontEditorCore: exportOptions.pdfUseFontEditorCore,
195+
fontAssets: pdfFontAssets,
196+
// We normalize assets to TTF up-front so writer-level conversion is not required.
197+
useFontEditorCore: false,
189198
});
190199
const doc = await renderIR(ir, w);
191200
await doc.finalize();
@@ -410,3 +419,226 @@ function shouldCollectFontAssets(format, exportOptions) {
410419
function normalizeFontAssets(fontAssets) {
411420
return normalizeTransferredFontAssets(fontAssets);
412421
}
422+
423+
async function preparePdfFontAssets(fontAssets, allowConversion) {
424+
if (!fontAssets || !Array.isArray(fontAssets.faces) || fontAssets.faces.length === 0) {
425+
return undefined;
426+
}
427+
428+
const faces = [];
429+
430+
for (const face of fontAssets.faces) {
431+
const convertedSource = await pickPdfFontSource(face, allowConversion);
432+
if (!convertedSource) continue;
433+
434+
faces.push({
435+
...face,
436+
sources: [convertedSource],
437+
});
438+
}
439+
440+
return faces.length > 0 ? { faces } : undefined;
441+
}
442+
443+
async function pickPdfFontSource(face, allowConversion) {
444+
const candidates = rankPdfFontSources(face?.sources);
445+
446+
for (const source of candidates) {
447+
const data = toUint8Array(source?.data);
448+
const format = resolveFontSourceFormat(source?.format, data);
449+
if (!format || !data) continue;
450+
451+
if (format === 'ttf') {
452+
return {
453+
...source,
454+
format: 'ttf',
455+
mimeType: 'font/ttf',
456+
data,
457+
};
458+
}
459+
460+
if (!allowConversion || !PDF_CONVERTIBLE_FONT_SOURCE_FORMATS.has(format)) {
461+
continue;
462+
}
463+
464+
try {
465+
const convertedData = await convertFontSourceToTtf(data, format);
466+
if (!convertedData) continue;
467+
468+
return {
469+
...source,
470+
format: 'ttf',
471+
mimeType: 'font/ttf',
472+
data: convertedData,
473+
};
474+
} catch (error) {
475+
console.warn(
476+
`[Web2Vector] Failed to convert font source to TTF for PDF (${face?.family || 'unknown family'}, ${format}):`,
477+
error
478+
);
479+
}
480+
}
481+
482+
return null;
483+
}
484+
485+
function rankPdfFontSources(sources) {
486+
if (!Array.isArray(sources) || sources.length === 0) return [];
487+
488+
return [...sources]
489+
.map((source) => ({
490+
source,
491+
format: normalizeFontSourceFormat(source?.format),
492+
}))
493+
.filter((entry) => Boolean(entry.format))
494+
.sort((left, right) => {
495+
const leftRank = getPdfFontSourcePriority(left.format);
496+
const rightRank = getPdfFontSourcePriority(right.format);
497+
498+
return leftRank - rightRank;
499+
})
500+
.map((entry) => entry.source);
501+
}
502+
503+
function getPdfFontSourcePriority(format) {
504+
const rank = PDF_FONT_SOURCE_PRIORITY.indexOf(format);
505+
return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
506+
}
507+
508+
function normalizeFontSourceFormat(format) {
509+
if (typeof format !== 'string') return null;
510+
return format.trim().toLowerCase();
511+
}
512+
513+
function resolveFontSourceFormat(declaredFormat, data) {
514+
const detectedFormat = detectFontBinaryFormat(data);
515+
if (detectedFormat) {
516+
return detectedFormat;
517+
}
518+
519+
return normalizeFontSourceFormat(declaredFormat);
520+
}
521+
522+
function detectFontBinaryFormat(data) {
523+
if (!(data instanceof Uint8Array) || data.length < 4) return null;
524+
525+
const signature = String.fromCharCode(data[0], data[1], data[2], data[3]);
526+
if (signature === 'wOFF') return 'woff';
527+
if (signature === 'wOF2') return 'woff2';
528+
if (signature === 'OTTO') return 'otf';
529+
if (signature === 'true') return 'ttf';
530+
if (data[0] === 0x00 && data[1] === 0x01 && data[2] === 0x00 && data[3] === 0x00) return 'ttf';
531+
532+
return null;
533+
}
534+
535+
function toUint8Array(value) {
536+
if (value instanceof Uint8Array) {
537+
return value;
538+
}
539+
540+
if (ArrayBuffer.isView(value)) {
541+
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
542+
}
543+
544+
if (value instanceof ArrayBuffer) {
545+
return new Uint8Array(value.slice(0));
546+
}
547+
548+
if (Array.isArray(value)) {
549+
return Uint8Array.from(value);
550+
}
551+
552+
return null;
553+
}
554+
555+
function toArrayBufferCopy(value) {
556+
const data = toUint8Array(value);
557+
if (!data) return null;
558+
559+
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
560+
}
561+
562+
async function convertFontSourceToTtf(data, format) {
563+
const normalizedFormat = normalizeFontSourceFormat(format);
564+
if (normalizedFormat === 'ttf') {
565+
return data;
566+
}
567+
568+
if (!PDF_CONVERTIBLE_FONT_SOURCE_FORMATS.has(normalizedFormat)) {
569+
return null;
570+
}
571+
572+
const fontEditorCore = await getFontEditorCore();
573+
if (!fontEditorCore?.createFont) {
574+
return null;
575+
}
576+
577+
if (normalizedFormat === 'woff2') {
578+
await ensureWoff2Runtime(fontEditorCore);
579+
}
580+
581+
const fontBuffer = toArrayBufferCopy(data);
582+
if (!fontBuffer) {
583+
return null;
584+
}
585+
586+
const readOptions = {
587+
type: normalizedFormat,
588+
};
589+
590+
if (normalizedFormat === 'woff') {
591+
readOptions.inflate = (compressedData) => pako.inflate(compressedData);
592+
}
593+
594+
const font = fontEditorCore.createFont(fontBuffer, readOptions);
595+
const ttfBuffer = font.write({
596+
type: 'ttf',
597+
toBuffer: false,
598+
});
599+
600+
return toUint8Array(ttfBuffer);
601+
}
602+
603+
async function getFontEditorCore() {
604+
if (!fontEditorCorePromise) {
605+
fontEditorCorePromise = import('../../node_modules/fonteditor-core/lib/main.js')
606+
.then((mod) => mod?.default ?? mod)
607+
.catch((error) => {
608+
fontEditorCorePromise = null;
609+
throw error;
610+
});
611+
}
612+
613+
return fontEditorCorePromise;
614+
}
615+
616+
async function ensureWoff2Runtime(fontEditorCore) {
617+
if (fontEditorCore?.woff2?.isInited?.()) {
618+
return;
619+
}
620+
621+
if (!fontEditorCore?.woff2?.init) {
622+
throw new Error('fonteditor-core WOFF2 runtime is unavailable');
623+
}
624+
625+
if (!woff2InitPromise) {
626+
const wasmUrl = resolveWoff2WasmUrl();
627+
if (!wasmUrl) {
628+
throw new Error('Unable to resolve WOFF2 wasm URL');
629+
}
630+
631+
woff2InitPromise = fontEditorCore.woff2.init(wasmUrl).catch((error) => {
632+
woff2InitPromise = null;
633+
throw error;
634+
});
635+
}
636+
637+
await woff2InitPromise;
638+
}
639+
640+
function resolveWoff2WasmUrl() {
641+
return typeof extensionApi?.runtime?.getURL === 'function'
642+
? extensionApi.runtime.getURL('woff2.wasm')
643+
: null;
644+
}

0 commit comments

Comments
 (0)