Skip to content

Commit 9e65844

Browse files
committed
Large exports no longer go through one oversized runtime.sendMessage payload. src/content/run-export.js now streams the rendered file to the background in 4 MiB chunks over a runtime port, and src/background/service-worker.js reassembles those chunks into a Blob URL before calling downloads.download. That removes the 64 MiB cap the old base64 data URL path was hitting.
I also changed the bitmap fallback so one tainted remote image no longer causes all page images to disappear. src/content/run-export.js now drops only image nodes that are likely to taint the canvas, while keeping embedded, blob, and same-origin images. The filtering logic is covered in tests/export-utils.test.js, and the streamed download path is covered in tests/service-worker.test.js. Validation passed: npm test and npm run build. Reload the unpacked extension so the rebuilt dist assets are used. Cross-origin images that the browser refuses to expose are still a real platform limitation. With the current permissions, those can still be omitted in bitmap fallbacks or remain external in some vector/document outputs. If you want, the next step is to add optional broader host permissions and a background fetch path so the extension can try to embed more CDN-hosted images. Removed the popup from the generic runtime.onMessage path again, leaving only the reconnecting dedicated status port, then rebuilt and reran the full test suite. That keeps the popup from competing with export transfer acknowledgements while still preserving status updates across service-worker restarts. I removed the popup from generic runtime.onMessage handling again, so only the background can answer export transfer messages now. The popup still has the reconnecting dedicated status port, so it can receive export-complete / export-error updates without participating in the transfer ack path. I rebuilt the extension and reran npm test; the suite still passes. Reload the unpacked extension again before retrying. If the Export transfer did not receive an acknowledgement error still appears after this reload, that strongly points away from the popup and toward the injected page-side sender or the service worker not handling the transfer message at all in real Chrome. At that point the next step will be to add explicit transfer-stage diagnostics in the popup/background so we can see whether it fails on export-transfer-start, first chunk, or complete.
1 parent 1d485f0 commit 9e65844

11 files changed

Lines changed: 724 additions & 182 deletions

package-lock.json

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

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "web2vector-extension",
33
"version": "1.0.0",
4-
"description": "Browser extension to export web pages to SVG, DXF, DWG, EMF, PDF, HTML, PNG, JPEG, and WebP using layout2vector",
4+
"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",
77
"scripts": {
@@ -16,14 +16,15 @@
1616
"upload": "node scripts/upload-to-store.mjs"
1717
},
1818
"dependencies": {
19-
"@node-projects/layout2vector": "^5.7.0",
20-
"get-box-quads-polyfill": "^4.32.0"
19+
"@chenglou/pretext": "^0.0.6",
20+
"@node-projects/layout2vector": "^5.15.0",
21+
"get-box-quads-polyfill": "^4.34.0"
2122
},
2223
"devDependencies": {
2324
"@resvg/resvg-js": "^2.6.2",
2425
"archiver": "^7.0.1",
2526
"chrome-webstore-upload": "^4.0.3",
2627
"esbuild": "^0.28.0",
27-
"vitest": "^4.1.4"
28+
"vitest": "^4.1.5"
2829
}
2930
}

src/background/service-worker.js

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { FORMATS, CATEGORIES } from '../shared/formats.js';
22
import { extensionApi } from '../shared/extension-api.js';
3+
import { base64ToBytes } from '../shared/export-transfer.js';
4+
import { POPUP_STATUS_PORT_NAME } from '../shared/popup-status.js';
35

46
const pendingDownloadUrls = new Map();
7+
const pendingTransfers = new Map();
8+
const popupPorts = new Set();
59

610
extensionApi.downloads.onChanged?.addListener((delta) => {
711
const state = delta.state?.current;
@@ -38,23 +42,53 @@ extensionApi.contextMenus.onClicked.addListener((info, tab) => {
3842
startExport(tab.id, format);
3943
});
4044

45+
extensionApi.runtime.onConnect?.addListener((port) => {
46+
if (port.name !== POPUP_STATUS_PORT_NAME) return;
47+
48+
popupPorts.add(port);
49+
port.onDisconnect.addListener(() => {
50+
popupPorts.delete(port);
51+
});
52+
});
53+
4154
// ── Message handling ──────────────────────────────────────
42-
extensionApi.runtime.onMessage.addListener((message, sender) => {
55+
extensionApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
4356
// From popup: start an export
4457
if (message.action === 'export') {
4558
extensionApi.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
4659
if (tab) startExport(tab.id, message.format);
4760
});
61+
return;
4862
}
4963

5064
// From content script: export finished
51-
if (message.type === 'export-result' && sender.tab) {
65+
if (message.type === 'export-result') {
5266
handleResult(message);
67+
return;
5368
}
5469

5570
// From content script: export error
56-
if (message.type === 'export-error' && sender.tab) {
71+
if (message.type === 'export-error') {
5772
notifyPopup('export-error', { error: message.error });
73+
sendResponse({ ok: true });
74+
return;
75+
}
76+
77+
if (message.type === 'export-transfer-start') {
78+
sendResponse(handleTransferStart(message, sender));
79+
return;
80+
}
81+
82+
if (message.type === 'export-transfer-chunk') {
83+
sendResponse(handleTransferChunk(message, sender));
84+
return;
85+
}
86+
87+
if (message.type === 'export-transfer-complete') {
88+
void finalizeTransfer(message, sender)
89+
.then((response) => sendResponse(response))
90+
.catch((error) => sendResponse({ ok: false, error: error.message }));
91+
return true;
5892
}
5993
});
6094

@@ -128,6 +162,95 @@ async function handleResult({ dataUrl, filename }) {
128162
}
129163
}
130164

165+
function handleTransferStart(message, sender) {
166+
if (!message.transferId || typeof message.transferId !== 'string') {
167+
return { ok: false, error: 'Missing export transfer id' };
168+
}
169+
170+
pendingTransfers.set(getTransferKey(sender, message.transferId), {
171+
filename: message.filename,
172+
mime: message.mime || 'application/octet-stream',
173+
expectedSize: message.size ?? null,
174+
receivedSize: 0,
175+
chunks: [],
176+
});
177+
178+
return { ok: true };
179+
}
180+
181+
function handleTransferChunk(message, sender) {
182+
try {
183+
const transfer = requireTransfer(message, sender);
184+
const chunk = decodeExportChunk(message.chunkBase64);
185+
transfer.chunks.push(chunk);
186+
transfer.receivedSize += chunk.byteLength;
187+
return { ok: true };
188+
} catch (error) {
189+
return { ok: false, error: error.message };
190+
}
191+
}
192+
193+
async function finalizeTransfer(message, sender) {
194+
const transfer = requireTransfer(message, sender);
195+
const transferKey = getTransferKey(sender, message.transferId);
196+
197+
try {
198+
if (transfer.expectedSize !== null && transfer.receivedSize !== transfer.expectedSize) {
199+
throw new Error('Export stream ended before all bytes were received');
200+
}
201+
202+
const blob = new Blob(transfer.chunks, { type: transfer.mime });
203+
void handleResultBlob(blob, transfer.filename).catch(() => {});
204+
return { ok: true };
205+
} finally {
206+
pendingTransfers.delete(transferKey);
207+
}
208+
}
209+
210+
async function handleResultBlob(blob, filename) {
211+
let objectUrl = null;
212+
213+
try {
214+
const downloadTarget = await createDownloadTargetFromBlob(blob);
215+
objectUrl = downloadTarget.objectUrl;
216+
const downloadId = await extensionApi.downloads.download({
217+
url: downloadTarget.url,
218+
filename,
219+
saveAs: true,
220+
});
221+
222+
if (objectUrl !== null) {
223+
pendingDownloadUrls.set(downloadId, objectUrl);
224+
}
225+
226+
notifyPopup('export-complete');
227+
} catch (err) {
228+
if (objectUrl !== null) {
229+
URL.revokeObjectURL(objectUrl);
230+
}
231+
232+
notifyPopup('export-error', { error: err.message });
233+
throw err;
234+
}
235+
}
236+
237+
async function createDownloadTargetFromBlob(blob) {
238+
if (shouldUseObjectUrlForBlobDownload()) {
239+
const objectUrl = URL.createObjectURL(blob);
240+
return { url: objectUrl, objectUrl };
241+
}
242+
243+
return {
244+
url: await blobToDataUrl(blob),
245+
objectUrl: null,
246+
};
247+
}
248+
249+
function shouldUseObjectUrlForBlobDownload() {
250+
return extensionApi === globalThis.browser &&
251+
typeof URL.createObjectURL === 'function';
252+
}
253+
131254
function createDownloadObjectUrl(dataUrl) {
132255
if (!shouldUseObjectUrlForDownload(dataUrl)) return null;
133256
return URL.createObjectURL(dataUrlToBlob(dataUrl));
@@ -160,7 +283,64 @@ function dataUrlToBlob(dataUrl) {
160283
return new Blob([bytes], { type: mime });
161284
}
162285

286+
async function blobToDataUrl(blob) {
287+
const bytes = new Uint8Array(await blob.arrayBuffer());
288+
const base64 = bytesToBase64(bytes);
289+
return `data:${blob.type || 'application/octet-stream'};base64,${base64}`;
290+
}
291+
292+
function bytesToBase64(bytes) {
293+
const chunkSize = 0x8000;
294+
let binary = '';
295+
296+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
297+
const chunk = bytes.subarray(offset, offset + chunkSize);
298+
binary += String.fromCharCode(...chunk);
299+
}
300+
301+
return btoa(binary);
302+
}
303+
304+
function decodeExportChunk(chunkBase64) {
305+
if (typeof chunkBase64 !== 'string' || chunkBase64.length === 0) {
306+
throw new Error('Invalid export chunk payload');
307+
}
308+
309+
return base64ToBytes(chunkBase64);
310+
}
311+
312+
function requireTransfer(message, sender) {
313+
const transferId = message?.transferId;
314+
if (!transferId || typeof transferId !== 'string') {
315+
throw new Error('Missing export transfer id');
316+
}
317+
318+
const transfer = pendingTransfers.get(getTransferKey(sender, transferId));
319+
if (!transfer) {
320+
throw new Error('Export stream was not initialized');
321+
}
322+
323+
return transfer;
324+
}
325+
326+
function getTransferKey(sender, transferId) {
327+
const tabId = sender.tab?.id ?? 'no-tab';
328+
const frameId = sender.frameId ?? 0;
329+
const documentId = sender.documentId ?? 'no-document';
330+
return `${tabId}:${frameId}:${documentId}:${transferId}`;
331+
}
332+
163333
// ── Notify popup (best-effort – popup may be closed) ──────
164334
function notifyPopup(action, extra = {}) {
165-
extensionApi.runtime.sendMessage({ action, ...extra }).catch(() => {});
335+
const message = { action, ...extra };
336+
337+
for (const port of popupPorts) {
338+
try {
339+
port.postMessage(message);
340+
} catch {
341+
popupPorts.delete(port);
342+
}
343+
}
344+
345+
extensionApi.runtime.sendMessage(message).catch(() => {});
166346
}

src/content/export-utils.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function createExportBlob(data, mime) {
2+
const bytes = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
3+
return new Blob([bytes], { type: mime });
4+
}
5+
6+
export function stripPotentiallyTaintedImages(irNodes, options = {}) {
7+
const baseUrl = options.baseUrl ?? globalThis.document?.baseURI ?? globalThis.location?.href;
8+
const pageOrigin = options.pageOrigin ?? globalThis.location?.origin ?? null;
9+
10+
return irNodes.filter((node) => {
11+
if (node?.type !== 'image') return true;
12+
return isLikelyCanvasSafeImageSource(node.dataUrl, { baseUrl, pageOrigin });
13+
});
14+
}
15+
16+
export function isLikelyCanvasSafeImageSource(source, options = {}) {
17+
if (typeof source !== 'string' || source.length === 0) return false;
18+
if (source.startsWith('data:') || source.startsWith('blob:')) return true;
19+
20+
try {
21+
const url = new URL(source, options.baseUrl);
22+
if (!options.pageOrigin) return false;
23+
return url.origin === options.pageOrigin;
24+
} catch {
25+
return false;
26+
}
27+
}

0 commit comments

Comments
 (0)