@pixldocs/canvas-renderer 0.5.190 → 0.5.192

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/README.md CHANGED
@@ -612,3 +612,43 @@ function — there is now exactly one resolver shared across the entire
612
612
  stack. Anything that blurs correctly in one place blurs identically in
613
613
  the other; visual/UX bugs introduced in one pipeline cannot diverge
614
614
  silently from the other.
615
+
616
+ ## Reliable PDF photo embedding across browsers (v0.5.191+)
617
+
618
+ Form-driven apps that embed **user-uploaded photos** (biodatas, resumes,
619
+ ID cards…) historically hit a Safari/iOS bug where the downloaded PDF
620
+ rendered without the photos even though the on-screen preview was fine.
621
+ The svg2pdf fast path internally re-rasterises `data:image/*` sources
622
+ through a tainted offscreen canvas roundtrip, which Safari silently drops
623
+ for large camera-roll JPEGs.
624
+
625
+ v0.5.191 fixes this with two new options:
626
+
627
+ ```ts
628
+ const renderer = new PixldocsRenderer({
629
+ supabaseUrl, supabaseAnonKey,
630
+ // Force the per-element Fabric → jsPDF.addImage path on every export.
631
+ // Recommended for biodata-style flows that always have user photos.
632
+ forcePerElementPdf: true, // true | false | 'auto' (default)
633
+ // Down-scale any data:image/* whose longest edge > 2048 to JPEG-0.85
634
+ // before mounting into Fabric. Prevents Safari from silently dropping
635
+ // multi-megapixel camera-roll JPEGs in jsPDF.addImage.
636
+ maxImageEdgePx: 2048, // 0 to disable
637
+ });
638
+
639
+ const pdf = await renderer.renderPdfFromForm({
640
+ templateId, formSchemaId, sectionState, title,
641
+ // Per-call overrides also accepted:
642
+ // forcePerElementPdf: 'auto',
643
+ // maxImageEdgePx: 2400,
644
+ });
645
+ ```
646
+
647
+ **Defaults:** `forcePerElementPdf: 'auto'` enables the safe path only when
648
+ the resolved config contains a `data:image/(jpeg|png|webp)` source **and**
649
+ the host is Safari / iOS (incl. iPadOS desktop spoof). Editors and
650
+ non-Safari hosts keep the vector fast path unchanged.
651
+
652
+ Hosts that previously monkey-patched `captureFabricCanvasSvgForPdf` to
653
+ throw (the unofficial BioMaker workaround) can delete that patch after
654
+ upgrading.
@@ -16889,9 +16889,9 @@ function captureFabricCanvasSvgForPdf(fabricInstance, canvasWidth, canvasHeight)
16889
16889
  }
16890
16890
  return svgString;
16891
16891
  }
16892
- const resolvedPackageVersion = "0.5.190";
16892
+ const resolvedPackageVersion = "0.5.192";
16893
16893
  const PACKAGE_VERSION = resolvedPackageVersion;
16894
- const DEPLOYMENT_VERSION_MARKER = "__PIXLDOCS_CANVAS_RENDERER_VERSION__:0.5.190";
16894
+ const DEPLOYMENT_VERSION_MARKER = "__PIXLDOCS_CANVAS_RENDERER_VERSION__:0.5.192";
16895
16895
  const roundParityValue = (value) => {
16896
16896
  if (typeof value !== "number") return value;
16897
16897
  return Number.isFinite(value) ? Number(value.toFixed(3)) : value;
@@ -16918,6 +16918,96 @@ function isFabricTextboxLike(obj) {
16918
16918
  function isFabricGroupLike(obj) {
16919
16919
  return !!obj && (obj instanceof fabric.Group || obj.type === "group" || Array.isArray(obj._objects) && typeof obj.getObjects === "function");
16920
16920
  }
16921
+ function configHasUserDataImage(config) {
16922
+ let found = false;
16923
+ const isRasterDataUrl = (u) => typeof u === "string" && /^data:image\/(jpeg|jpg|png|webp)[;,]/i.test(u);
16924
+ const walk = (nodes) => {
16925
+ if (found || !Array.isArray(nodes)) return;
16926
+ for (const node of nodes) {
16927
+ if (!node || typeof node !== "object") continue;
16928
+ if (node.type === "image" && (isRasterDataUrl(node.src) || isRasterDataUrl(node.imageUrl))) {
16929
+ found = true;
16930
+ return;
16931
+ }
16932
+ if (Array.isArray(node.children)) walk(node.children);
16933
+ }
16934
+ };
16935
+ for (const page of config.pages ?? []) {
16936
+ walk((page == null ? void 0 : page.children) ?? []);
16937
+ if (found) break;
16938
+ }
16939
+ return found;
16940
+ }
16941
+ function detectSafariOrIos() {
16942
+ try {
16943
+ if (typeof navigator === "undefined") return false;
16944
+ const ua = navigator.userAgent || "";
16945
+ const vendor = navigator.vendor || "";
16946
+ const isIOS = /iPad|iPhone|iPod/.test(ua) || // iPadOS 13+ reports as Mac with touch
16947
+ /Macintosh/.test(ua) && typeof navigator.maxTouchPoints === "number" && navigator.maxTouchPoints > 1;
16948
+ const isSafariDesktop = /Safari/.test(ua) && /Apple/.test(vendor) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua);
16949
+ return isIOS || isSafariDesktop;
16950
+ } catch {
16951
+ return false;
16952
+ }
16953
+ }
16954
+ async function downscaleConfigRasterImages(config, maxEdgePx) {
16955
+ if (!maxEdgePx || maxEdgePx <= 0) return 0;
16956
+ if (typeof document === "undefined") return 0;
16957
+ const targets = [];
16958
+ const isRasterDataUrl = (u) => typeof u === "string" && /^data:image\/(jpeg|jpg|png|webp)[;,]/i.test(u);
16959
+ const walk = (nodes) => {
16960
+ if (!Array.isArray(nodes)) return;
16961
+ for (const node of nodes) {
16962
+ if (!node || typeof node !== "object") continue;
16963
+ if (node.type === "image") {
16964
+ if (isRasterDataUrl(node.src)) targets.push({ node, field: "src" });
16965
+ else if (isRasterDataUrl(node.imageUrl)) targets.push({ node, field: "imageUrl" });
16966
+ }
16967
+ if (Array.isArray(node.children)) walk(node.children);
16968
+ }
16969
+ };
16970
+ for (const page of config.pages ?? []) walk((page == null ? void 0 : page.children) ?? []);
16971
+ if (targets.length === 0) return 0;
16972
+ const shrinkOne = async (dataUrl) => {
16973
+ try {
16974
+ const img = await new Promise((resolve, reject) => {
16975
+ const el = new Image();
16976
+ el.onload = () => resolve(el);
16977
+ el.onerror = (e) => reject(e);
16978
+ el.decoding = "sync";
16979
+ el.src = dataUrl;
16980
+ });
16981
+ const w = img.naturalWidth, h = img.naturalHeight;
16982
+ if (!w || !h) return null;
16983
+ const longest = Math.max(w, h);
16984
+ if (longest <= maxEdgePx) return null;
16985
+ const scale = maxEdgePx / longest;
16986
+ const tw = Math.max(1, Math.round(w * scale));
16987
+ const th = Math.max(1, Math.round(h * scale));
16988
+ const canvas = document.createElement("canvas");
16989
+ canvas.width = tw;
16990
+ canvas.height = th;
16991
+ const ctx = canvas.getContext("2d");
16992
+ if (!ctx) return null;
16993
+ ctx.fillStyle = "#ffffff";
16994
+ ctx.fillRect(0, 0, tw, th);
16995
+ ctx.drawImage(img, 0, 0, tw, th);
16996
+ return canvas.toDataURL("image/jpeg", 0.85);
16997
+ } catch {
16998
+ return null;
16999
+ }
17000
+ };
17001
+ let shrunk = 0;
17002
+ for (const { node, field } of targets) {
17003
+ const next = await shrinkOne(String(node[field]));
17004
+ if (next) {
17005
+ node[field] = next;
17006
+ shrunk++;
17007
+ }
17008
+ }
17009
+ return shrunk;
17010
+ }
16921
17011
  let __underlineFixInstalled = false;
16922
17012
  function installUnderlineFix(fab) {
16923
17013
  var _a;
@@ -17270,7 +17360,9 @@ class PixldocsRenderer {
17270
17360
  async renderPdf(templateConfig, options) {
17271
17361
  return this.renderPdfViaClientExport(templateConfig, {
17272
17362
  title: options == null ? void 0 : options.title,
17273
- textMode: options == null ? void 0 : options.textMode
17363
+ textMode: options == null ? void 0 : options.textMode,
17364
+ forcePerElementPdf: options == null ? void 0 : options.forcePerElementPdf,
17365
+ maxImageEdgePx: options == null ? void 0 : options.maxImageEdgePx
17274
17366
  });
17275
17367
  }
17276
17368
  /**
@@ -17278,7 +17370,7 @@ class PixldocsRenderer {
17278
17370
  * This is the primary PDF export API — mirrors renderFromForm() but returns a PDF.
17279
17371
  */
17280
17372
  async renderPdfFromForm(options) {
17281
- const { templateId, formSchemaId, sectionState, themeId, watermark, watermarkOptions, prefetched, title, fontBaseUrl, textMode } = options;
17373
+ const { templateId, formSchemaId, sectionState, themeId, watermark, watermarkOptions, prefetched, title, fontBaseUrl, textMode, forcePerElementPdf, maxImageEdgePx } = options;
17282
17374
  const resolved = await resolveFromForm({
17283
17375
  templateId,
17284
17376
  formSchemaId,
@@ -17299,7 +17391,9 @@ class PixldocsRenderer {
17299
17391
  return this.renderPdfViaClientExport(configToRender, {
17300
17392
  title: title ?? resolved.config.name,
17301
17393
  watermark: shouldWatermark,
17302
- textMode
17394
+ textMode,
17395
+ forcePerElementPdf,
17396
+ maxImageEdgePx
17303
17397
  });
17304
17398
  }
17305
17399
  async renderById(templateId, formData, options) {
@@ -17340,6 +17434,31 @@ class PixldocsRenderer {
17340
17434
  const { setPackageApiUrl: setPackageApiUrl2 } = await Promise.resolve().then(() => appApi);
17341
17435
  setPackageApiUrl2(this.config.imageProxyUrl);
17342
17436
  const cloned = JSON.parse(JSON.stringify(templateConfig));
17437
+ const callForce = options.forcePerElementPdf;
17438
+ const cfgForce = this.config.forcePerElementPdf;
17439
+ const forceMode = callForce !== void 0 ? callForce : cfgForce !== void 0 ? cfgForce : "auto";
17440
+ const hasUserDataImage = configHasUserDataImage(cloned);
17441
+ const isSafariLike = detectSafariOrIos();
17442
+ const shouldForcePerElement = forceMode === true ? true : forceMode === false ? false : hasUserDataImage && isSafariLike;
17443
+ const maxEdgeOpt = options.maxImageEdgePx ?? this.config.maxImageEdgePx;
17444
+ const effectiveMaxEdge = typeof maxEdgeOpt === "number" ? Math.max(0, maxEdgeOpt | 0) : shouldForcePerElement ? 2048 : 0;
17445
+ if (effectiveMaxEdge > 0) {
17446
+ try {
17447
+ const downscaled = await downscaleConfigRasterImages(cloned, effectiveMaxEdge);
17448
+ if (downscaled > 0) {
17449
+ console.log(`[canvas-renderer][pdf-unified] downscaled ${downscaled} raster image(s) to <=${effectiveMaxEdge}px edge`);
17450
+ }
17451
+ } catch (e) {
17452
+ console.warn("[canvas-renderer][pdf-unified] image downscale pass failed (continuing with originals):", e);
17453
+ }
17454
+ }
17455
+ console.log("[canvas-renderer][pdf-unified] export switches", {
17456
+ forceMode,
17457
+ hasUserDataImage,
17458
+ isSafariLike,
17459
+ shouldForcePerElement,
17460
+ effectiveMaxEdge
17461
+ });
17343
17462
  const stampPrefix = `__pixldocs_pdf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
17344
17463
  const pageIds = cloned.pages.map((p, i) => {
17345
17464
  const id = `${stampPrefix}_p${i}`;
@@ -17399,7 +17518,7 @@ class PixldocsRenderer {
17399
17518
  await this.waitForCanvasScene(container, cloned, i);
17400
17519
  }
17401
17520
  console.log(`[canvas-renderer][pdf-unified] mounted ${cloned.pages.length} page(s), handing off to client exportMultiPagePdf`);
17402
- const { exportMultiPagePdf, preparePagesForExport } = await import("./vectorPdfExport-DFGqTzvI.js");
17521
+ const { exportMultiPagePdf, preparePagesForExport } = await import("./vectorPdfExport-DuVMxQSS.js");
17403
17522
  const prepared = preparePagesForExport(
17404
17523
  cloned.pages,
17405
17524
  canvasWidth,
@@ -17409,7 +17528,28 @@ class PixldocsRenderer {
17409
17528
  title: options.title,
17410
17529
  watermark: !!options.watermark,
17411
17530
  returnBlob: true,
17412
- pdfTextMode: options.textMode ?? (cloned == null ? void 0 : cloned.pdfTextMode) ?? ((_a = cloned.canvas) == null ? void 0 : _a.n) ?? "auto"
17531
+ pdfTextMode: options.textMode ?? (cloned == null ? void 0 : cloned.pdfTextMode) ?? ((_a = cloned.canvas) == null ? void 0 : _a.n) ?? "auto",
17532
+ // IMPORTANT: We intentionally do NOT skip the live-canvas SVG fast path
17533
+ // here, even when `shouldForcePerElement` is true via auto-detection.
17534
+ //
17535
+ // Reason: skipping the fast path forces the config-space export, which
17536
+ // computes positions from stored values rather than the live Fabric
17537
+ // layout (post auto-shrink, post text-init, post crop-group bake).
17538
+ // That drift causes severe text overlap / mis-positioning in the PDF
17539
+ // while the on-screen preview (which uses the live canvas) still looks
17540
+ // correct. v0.5.191 shipped with this skip enabled and produced exactly
17541
+ // that regression.
17542
+ //
17543
+ // The Safari/iOS "missing photo" issue is solved end-to-end by the
17544
+ // `downscaleConfigRasterImages()` pre-pass above, which shrinks large
17545
+ // `data:image/*` JPEGs to a safe edge length before the canvas mounts.
17546
+ // That alone keeps Safari's SVG capture reliable without altering the
17547
+ // layout pipeline.
17548
+ //
17549
+ // We still honor an explicit `forcePerElementPdf: true` from the host
17550
+ // app (advanced opt-in for niche cases), but the auto path no longer
17551
+ // toggles this flag.
17552
+ skipLiveCanvasSvgFastPath: forceMode === true
17413
17553
  });
17414
17554
  if (!result || typeof result === "undefined") {
17415
17555
  throw new Error("exportMultiPagePdf returned no blob (returnBlob path failed)");
@@ -19544,7 +19684,7 @@ async function prepareLiveCanvasSvgForPdf(rawSvg, pageWidth, pageHeight, pageKey
19544
19684
  if (options == null ? void 0 : options.stripPageBackground) stripRootPageBackgroundFromSvg(svgToDraw);
19545
19685
  sanitizeSvgTreeForPdf(svgToDraw);
19546
19686
  try {
19547
- const { bakeTextAnchorPositionsFromLiveSvg, logTextMeasurementDiagnostic } = await import("./vectorPdfExport-DFGqTzvI.js");
19687
+ const { bakeTextAnchorPositionsFromLiveSvg, logTextMeasurementDiagnostic } = await import("./vectorPdfExport-DuVMxQSS.js");
19548
19688
  try {
19549
19689
  await logTextMeasurementDiagnostic(svgToDraw);
19550
19690
  } catch {
@@ -19944,4 +20084,4 @@ export {
19944
20084
  buildTeaserBlurFlatKeys as y,
19945
20085
  collectFontDescriptorsFromConfig as z
19946
20086
  };
19947
- //# sourceMappingURL=index-Dt4aPZtQ.js.map
20087
+ //# sourceMappingURL=index-CqgOWYwR.js.map