mnfst 0.5.73 → 0.5.75

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.
@@ -150,11 +150,33 @@ function initializeExportPlugin() {
150
150
  return `export-${ts}.${ext}`;
151
151
  }
152
152
 
153
+ // Transparent 1×1 PNG, used as a fallback when an inline image fails to fetch.
154
+ // html-to-image would otherwise reject the whole snapshot on a single CORS or 404.
155
+ const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=';
156
+
157
+ // html-to-image rejects with the raw `image.onerror` Event when the assembled
158
+ // SVG fails to decode (oversized clones, malformed inline assets, etc.).
159
+ // Translate those into something readable before logging.
160
+ function describeExportError(err) {
161
+ if (err && err.message) return err;
162
+ if (err && typeof Event !== 'undefined' && err instanceof Event) {
163
+ const tag = err.target && err.target.tagName ? err.target.tagName.toLowerCase() : 'image';
164
+ return new Error(
165
+ `failed to render ${tag} during export. ` +
166
+ `Common causes: cross-origin images without CORS headers, ` +
167
+ `an oversized target element, or a no-target snapshot of a complex page. ` +
168
+ `Pass a "target" option to scope the snapshot.`
169
+ );
170
+ }
171
+ return new Error(String(err));
172
+ }
173
+
153
174
  function emitError(format, err) {
154
- console.error('[x-export] export failed:', err);
175
+ const e = describeExportError(err);
176
+ console.error('[x-export] export failed:', e.message);
155
177
  try {
156
178
  window.dispatchEvent(new CustomEvent('manifest:export-error', {
157
- detail: { format, error: String(err && err.message || err) }
179
+ detail: { format, error: e.message }
158
180
  }));
159
181
  } catch { /* ignore */ }
160
182
  }
@@ -194,6 +216,10 @@ function initializeExportPlugin() {
194
216
  const out = {
195
217
  pixelRatio: Number(opts.resolution) > 0 ? Number(opts.resolution) : 2,
196
218
  cacheBust: true,
219
+ // A single cross-origin image without CORS headers would otherwise
220
+ // reject the whole snapshot. Substitute a transparent pixel so the
221
+ // export still produces a usable file.
222
+ imagePlaceholder: TRANSPARENT_PIXEL,
197
223
  filter
198
224
  };
199
225
  if (opts.backgroundColor) out.backgroundColor = opts.backgroundColor;
@@ -202,31 +228,76 @@ function initializeExportPlugin() {
202
228
  return out;
203
229
  }
204
230
 
231
+ // Run an html-to-image call once. If it rejects with a raw Event (the SVG
232
+ // failed to decode — usually a font or oversized clone issue), retry once
233
+ // with safer options before propagating the error. Most real-world failures
234
+ // recover on the second attempt.
235
+ async function snapshotWithFallback(lib, fn, target, so) {
236
+ try {
237
+ return await lib[fn](target, so);
238
+ } catch (err) {
239
+ if (err && err.message) throw err; // a real Error — don't mask it
240
+ const safer = { ...so, skipFonts: true, pixelRatio: Math.min(so.pixelRatio || 2, 1) };
241
+ return await lib[fn](target, safer);
242
+ }
243
+ }
244
+
245
+ // Chromium and Safari reject SVG-image decoding past roughly 16,000px on
246
+ // either axis. A long article at pixelRatio: 2 easily exceeds that. When
247
+ // the target is the whole body we cap dimensions so the assembled SVG
248
+ // stays decodable; explicit targets keep their natural size.
249
+ const SAFE_MAX_DIMENSION = 14000;
250
+
251
+ function applySafeDimensions(target, so) {
252
+ const isWholeBody = target === document.body || target === document.documentElement;
253
+ if (!isWholeBody) return so;
254
+ const rect = target.getBoundingClientRect();
255
+ const width = Math.max(rect.width, target.scrollWidth || 0);
256
+ const height = Math.max(rect.height, target.scrollHeight || 0);
257
+ const ratio = so.pixelRatio || 2;
258
+ const limit = SAFE_MAX_DIMENSION / ratio;
259
+ const out = { ...so };
260
+ if (width > limit) out.canvasWidth = Math.floor(limit);
261
+ if (height > limit) out.canvasHeight = Math.floor(limit);
262
+ return out;
263
+ }
264
+
205
265
  async function exportImage(opts, filename, ext) {
206
266
  const lib = await loadHtmlToImage();
207
267
  const target = resolveTarget(opts);
208
- const so = snapshotOptions(opts);
268
+ const so = applySafeDimensions(target, snapshotOptions(opts));
209
269
  let dataUrl;
210
- if (ext === 'png') dataUrl = await lib.toPng(target, so);
270
+ if (ext === 'png') dataUrl = await snapshotWithFallback(lib, 'toPng', target, so);
211
271
  else if (ext === 'jpeg') {
212
272
  if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
213
273
  so.quality = Number(opts.quality) > 0 && Number(opts.quality) <= 1
214
274
  ? Number(opts.quality)
215
275
  : 0.95;
216
- dataUrl = await lib.toJpeg(target, so);
276
+ dataUrl = await snapshotWithFallback(lib, 'toJpeg', target, so);
217
277
  } else if (ext === 'webp') {
218
- const blob = await lib.toBlob(target, { ...so, type: 'image/webp', quality: opts.quality || 0.95 });
278
+ const webpOpts = { ...so, type: 'image/webp', quality: opts.quality || 0.95 };
279
+ const blob = await snapshotWithFallback(lib, 'toBlob', target, webpOpts);
219
280
  dataUrl = URL.createObjectURL(blob);
220
281
  }
221
282
  triggerDownload(dataUrl, filename);
222
283
  }
223
284
 
285
+ // Whole-page PDF: route through the browser's native print pipeline.
286
+ // It handles multi-page layout, page breaks, vector text, and the page's
287
+ // own @media print CSS — far more reliable than rasterizing a long page
288
+ // and embedding it as a single image. The user sees the print dialog and
289
+ // picks "Save as PDF" (or any installed PDF printer). Element-scoped PDFs
290
+ // continue to use html-to-image + jsPDF, which is well-behaved at small
291
+ // target sizes.
224
292
  async function exportPdf(opts, filename) {
293
+ if (!opts.target) {
294
+ return printToPdf(filename);
295
+ }
225
296
  const [imgLib, jsPDFCtor] = await Promise.all([loadHtmlToImage(), loadJsPDF()]);
226
297
  const target = resolveTarget(opts);
227
- const so = snapshotOptions(opts);
298
+ const so = applySafeDimensions(target, snapshotOptions(opts));
228
299
  if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
229
- const dataUrl = await imgLib.toPng(target, so);
300
+ const dataUrl = await snapshotWithFallback(imgLib, 'toPng', target, so);
230
301
  const img = await loadImage(dataUrl);
231
302
  const orientation = img.width > img.height ? 'landscape' : 'portrait';
232
303
  const pdf = new jsPDFCtor({ orientation, unit: 'pt', format: opts.pageSize || 'a4' });
@@ -241,6 +312,20 @@ function initializeExportPlugin() {
241
312
  pdf.save(filename);
242
313
  }
243
314
 
315
+ function printToPdf(filename) {
316
+ // The browser's "Save as PDF" dialog seeds its default filename from
317
+ // document.title. Swap it briefly so the suggested name matches the
318
+ // user's intent, then restore after the dialog closes.
319
+ const original = document.title;
320
+ const cleaned = String(filename || '').replace(/\.pdf$/i, '') || original;
321
+ document.title = cleaned;
322
+ try { window.print(); }
323
+ finally {
324
+ // Wait one frame so the print dialog reads the swapped title first.
325
+ setTimeout(() => { document.title = original; }, 0);
326
+ }
327
+ }
328
+
244
329
  function effectivePageBackground() {
245
330
  let el = document.body;
246
331
  while (el && el !== document.documentElement.parentElement) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.73",
3
+ "version": "0.5.75",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",