@pixldocs/canvas-renderer 0.3.18 → 0.3.20

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/dist/index.d.ts CHANGED
@@ -43,6 +43,15 @@ export declare function collectFontDescriptorsFromConfig(config: TemplateConfig)
43
43
  */
44
44
  export declare function collectFontsFromConfig(config: TemplateConfig): Set<string>;
45
45
 
46
+ /**
47
+ * Collect all image asset URLs from a TemplateConfig that the Fabric canvas
48
+ * will request during rendering. This is the canonical list — the same logic
49
+ * that `getExpectedImageCount` uses, but returning URLs instead of a count.
50
+ *
51
+ * Walks **all pages** and recurses into children / groups.
52
+ */
53
+ export declare function collectImageUrls(config: TemplateConfig): string[];
54
+
46
55
  export declare interface DynamicField {
47
56
  id: string;
48
57
  label: string;
@@ -175,6 +184,19 @@ export declare class PixldocsRenderer {
175
184
  * This is the primary external API for the package.
176
185
  */
177
186
  renderFromForm(options: RenderFromFormOptions): Promise<RenderResult[]>;
187
+ /**
188
+ * Render a page and capture the Fabric canvas SVG output (vector, not raster).
189
+ * This is the key building block for client-side vector PDF export.
190
+ */
191
+ renderPageSvg(templateConfig: TemplateConfig, pageIndex?: number): Promise<SvgRenderResult>;
192
+ /**
193
+ * Render all pages and return SVG strings for each.
194
+ */
195
+ renderAllPageSvgs(templateConfig: TemplateConfig): Promise<SvgRenderResult[]>;
196
+ /**
197
+ * Resolve from V2 sectionState and return SVGs for all pages (for server vector PDF).
198
+ */
199
+ renderSvgsFromForm(options: Omit<RenderFromFormOptions, 'format' | 'quality' | 'scale' | 'pixelRatio'>): Promise<SvgRenderResult[]>;
178
200
  /**
179
201
  * Convenience: fetch by ID with simple flat data and render.
180
202
  */
@@ -184,6 +206,12 @@ export declare class PixldocsRenderer {
184
206
  private getNormalizedGradientStops;
185
207
  private paintPageBackground;
186
208
  private renderPageViaPreviewCanvas;
209
+ private captureSvgViaPreviewCanvas;
210
+ /**
211
+ * Find the Fabric.Canvas instance that belongs to a given container element,
212
+ * using the global __fabricCanvasRegistry (set by PageCanvas).
213
+ */
214
+ private getFabricCanvasFromContainer;
187
215
  }
188
216
 
189
217
  export declare interface RendererConfig {
@@ -282,6 +310,19 @@ export { SmartElementProps }
282
310
 
283
311
  export { SmartElementType }
284
312
 
313
+ export declare interface SvgRenderResult {
314
+ /** Raw SVG string from Fabric's toSVG() — clean, no viewport transforms */
315
+ svg: string;
316
+ /** Page width in CSS pixels */
317
+ width: number;
318
+ /** Page height in CSS pixels */
319
+ height: number;
320
+ /** Background color of the page */
321
+ backgroundColor: string;
322
+ /** Background gradient (if any) */
323
+ backgroundGradient?: any;
324
+ }
325
+
285
326
  export declare interface TemplateConfig {
286
327
  version?: string;
287
328
  name?: string;
@@ -316,4 +357,53 @@ export declare interface ThemeVariables {
316
357
  [variableName: string]: string;
317
358
  }
318
359
 
360
+ export declare interface WarmOptions {
361
+ /** AbortSignal to cancel in-flight prefetches (e.g. on route change). */
362
+ signal?: AbortSignal;
363
+ /** Image proxy base URL. Falls back to the value set via `setPackageApiUrl`. */
364
+ imageProxyUrl?: string;
365
+ }
366
+
367
+ /**
368
+ * Warm fonts **and** image assets for a fully-resolved `TemplateConfig`.
369
+ *
370
+ * Call this on idle / in the background before rendering so that `render()`
371
+ * resolves from browser caches instead of hitting the network.
372
+ *
373
+ * **Idempotent** — repeated calls for the same config are near-instant
374
+ * (fonts deduplicate internally; `fetch` hits HTTP cache).
375
+ *
376
+ * Does **not** mount a canvas or require React.
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * import { warmResolvedTemplateForPreview } from '@pixldocs/canvas-renderer';
381
+ *
382
+ * const controller = new AbortController();
383
+ * await warmResolvedTemplateForPreview(resolvedConfig, { signal: controller.signal });
384
+ * // Later: renderer.render(resolvedConfig) — fonts & images are cached
385
+ * ```
386
+ */
387
+ export declare function warmResolvedTemplateForPreview(config: TemplateConfig, options?: WarmOptions): Promise<void>;
388
+
389
+ /**
390
+ * Convenience: resolve a template from the database **and** warm its assets
391
+ * in a single call. Combines `resolveFromForm` + `warmResolvedTemplateForPreview`.
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * import { warmTemplateFromForm } from '@pixldocs/canvas-renderer';
396
+ *
397
+ * await warmTemplateFromForm({
398
+ * templateId: 'dc3fbb17-...',
399
+ * formSchemaId: 'b04cd362-...',
400
+ * sectionState: { ... },
401
+ * supabaseUrl: '...',
402
+ * supabaseAnonKey: '...',
403
+ * signal: controller.signal,
404
+ * });
405
+ * ```
406
+ */
407
+ export declare function warmTemplateFromForm(options: ResolveFromFormOptions & WarmOptions): Promise<void>;
408
+
319
409
  export { }
package/dist/index.js CHANGED
@@ -2643,6 +2643,17 @@ function isPrivateUrl(url) {
2643
2643
  }
2644
2644
  }
2645
2645
  function toPublicStorageUrl(url) {
2646
+ try {
2647
+ const parsed = new URL(url);
2648
+ const origin = parsed.origin;
2649
+ const signedMatch = parsed.pathname.match(/\/storage\/v1\/object\/sign\/([^?]+)/);
2650
+ if (signedMatch) {
2651
+ return `${origin}/storage/v1/object/public/${signedMatch[1]}`;
2652
+ }
2653
+ if (parsed.pathname.includes("/storage/v1/object/public/")) return url;
2654
+ } catch {
2655
+ return null;
2656
+ }
2646
2657
  return null;
2647
2658
  }
2648
2659
  function getProxiedImageUrl(imageUrl) {
@@ -2652,7 +2663,7 @@ function getProxiedImageUrl(imageUrl) {
2652
2663
  console.warn("[image-proxy] Skipping private URL:", imageUrl.substring(0, 80));
2653
2664
  return "";
2654
2665
  }
2655
- const publicUrl = toPublicStorageUrl();
2666
+ const publicUrl = toPublicStorageUrl(imageUrl);
2656
2667
  if (publicUrl) return publicUrl;
2657
2668
  return `${API_URL}/image-proxy?url=${encodeURIComponent(imageUrl)}`;
2658
2669
  }
@@ -10683,6 +10694,58 @@ class PixldocsRenderer {
10683
10694
  }
10684
10695
  return this.renderAllPages(configToRender, renderOpts);
10685
10696
  }
10697
+ /**
10698
+ * Render a page and capture the Fabric canvas SVG output (vector, not raster).
10699
+ * This is the key building block for client-side vector PDF export.
10700
+ */
10701
+ async renderPageSvg(templateConfig, pageIndex = 0) {
10702
+ const page = templateConfig.pages[pageIndex];
10703
+ if (!page) {
10704
+ throw new Error(`Page index ${pageIndex} not found (template has ${templateConfig.pages.length} pages)`);
10705
+ }
10706
+ await ensureFontsForResolvedConfig(templateConfig);
10707
+ const { setPackageApiUrl: setPackageApiUrl2 } = await Promise.resolve().then(() => appApi);
10708
+ setPackageApiUrl2(this.config.imageProxyUrl);
10709
+ const canvasWidth = templateConfig.canvas.width;
10710
+ const canvasHeight = templateConfig.canvas.height;
10711
+ return this.captureSvgViaPreviewCanvas(templateConfig, pageIndex, canvasWidth, canvasHeight);
10712
+ }
10713
+ /**
10714
+ * Render all pages and return SVG strings for each.
10715
+ */
10716
+ async renderAllPageSvgs(templateConfig) {
10717
+ await ensureFontsForResolvedConfig(templateConfig);
10718
+ const { setPackageApiUrl: setPackageApiUrl2 } = await Promise.resolve().then(() => appApi);
10719
+ setPackageApiUrl2(this.config.imageProxyUrl);
10720
+ const results = [];
10721
+ for (let i = 0; i < templateConfig.pages.length; i++) {
10722
+ const canvasWidth = templateConfig.canvas.width;
10723
+ const canvasHeight = templateConfig.canvas.height;
10724
+ results.push(await this.captureSvgViaPreviewCanvas(templateConfig, i, canvasWidth, canvasHeight));
10725
+ }
10726
+ return results;
10727
+ }
10728
+ /**
10729
+ * Resolve from V2 sectionState and return SVGs for all pages (for server vector PDF).
10730
+ */
10731
+ async renderSvgsFromForm(options) {
10732
+ const { templateId, formSchemaId, sectionState, themeId, watermark } = options;
10733
+ const resolved = await resolveFromForm({
10734
+ templateId,
10735
+ formSchemaId,
10736
+ sectionState,
10737
+ themeId,
10738
+ supabaseUrl: this.config.supabaseUrl,
10739
+ supabaseAnonKey: this.config.supabaseAnonKey
10740
+ });
10741
+ const shouldWatermark = watermark ?? resolved.price > 0;
10742
+ let configToRender = resolved.config;
10743
+ if (shouldWatermark) {
10744
+ const { injectWatermark } = await import("./canvasWatermark-CM85x4k7.js");
10745
+ configToRender = injectWatermark(configToRender);
10746
+ }
10747
+ return this.renderAllPageSvgs(configToRender);
10748
+ }
10686
10749
  /**
10687
10750
  * Convenience: fetch by ID with simple flat data and render.
10688
10751
  */
@@ -10718,6 +10781,7 @@ class PixldocsRenderer {
10718
10781
  return new Promise((resolve) => {
10719
10782
  const start = Date.now();
10720
10783
  let stableFrames = 0;
10784
+ let lastSummary = "";
10721
10785
  const isRenderableImage = (value) => value instanceof HTMLImageElement && value.complete && value.naturalWidth > 0 && value.naturalHeight > 0;
10722
10786
  const collectRenderableImages = (obj, seen) => {
10723
10787
  if (!obj || typeof obj !== "object") return;
@@ -10747,6 +10811,25 @@ class PixldocsRenderer {
10747
10811
  return null;
10748
10812
  };
10749
10813
  const settle = () => requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
10814
+ const getImageDebugInfo = (obj, bucket) => {
10815
+ if (!obj || typeof obj !== "object") return;
10816
+ const candidates = [obj._element, obj._originalElement, obj._filteredEl, obj._cacheCanvasEl];
10817
+ for (const candidate of candidates) {
10818
+ if (candidate instanceof HTMLImageElement) {
10819
+ bucket.push({
10820
+ id: obj.__docuforgeId || obj.id || "unknown",
10821
+ src: (candidate.currentSrc || candidate.src || "").slice(0, 240),
10822
+ complete: candidate.complete,
10823
+ naturalWidth: candidate.naturalWidth,
10824
+ naturalHeight: candidate.naturalHeight
10825
+ });
10826
+ }
10827
+ }
10828
+ const nested = Array.isArray(obj._objects) ? obj._objects : Array.isArray(obj.objects) ? obj.objects : [];
10829
+ for (const child of nested) {
10830
+ getImageDebugInfo(child, bucket);
10831
+ }
10832
+ };
10750
10833
  const check = () => {
10751
10834
  const elapsed = Date.now() - start;
10752
10835
  const domImages = Array.from(container.querySelectorAll("img"));
@@ -10756,12 +10839,18 @@ class PixldocsRenderer {
10756
10839
  const renderableImages = /* @__PURE__ */ new Set();
10757
10840
  const fabricReady = fabricObjects.every((obj) => collectRenderableImages(obj, renderableImages) !== false);
10758
10841
  const actualImageCount = Math.max(domImages.length, renderableImages.size);
10759
- !!fabricCanvas && !!(fabricCanvas.lowerCanvasEl || fabricCanvas.upperCanvasEl);
10842
+ const canvasReady = !!fabricCanvas && !!(fabricCanvas.lowerCanvasEl || fabricCanvas.upperCanvasEl);
10760
10843
  const hasExpectedAssets = expectedImageCount === 0 ? true : actualImageCount >= expectedImageCount;
10761
10844
  const ready = allDomLoaded && fabricReady && hasExpectedAssets;
10845
+ const summary = `expected=${expectedImageCount} actual=${actualImageCount} dom=${domImages.length} fabricReady=${fabricReady} domReady=${allDomLoaded} canvasReady=${canvasReady}`;
10846
+ if (summary !== lastSummary) {
10847
+ lastSummary = summary;
10848
+ console.log(`[canvas-renderer][asset-wait] ${summary}`);
10849
+ }
10762
10850
  if (ready) {
10763
10851
  stableFrames += 1;
10764
10852
  if (stableFrames >= 2) {
10853
+ console.log(`[canvas-renderer][asset-wait] ready after ${elapsed}ms (${summary})`);
10765
10854
  settle();
10766
10855
  return;
10767
10856
  }
@@ -10769,6 +10858,20 @@ class PixldocsRenderer {
10769
10858
  stableFrames = 0;
10770
10859
  }
10771
10860
  if (elapsed >= maxWaitMs) {
10861
+ const fabricImageDebug = [];
10862
+ for (const obj of fabricObjects) {
10863
+ getImageDebugInfo(obj, fabricImageDebug);
10864
+ }
10865
+ const domImageDebug = domImages.map((img, index) => ({
10866
+ index,
10867
+ src: (img.currentSrc || img.src || "").slice(0, 240),
10868
+ complete: img.complete,
10869
+ naturalWidth: img.naturalWidth,
10870
+ naturalHeight: img.naturalHeight
10871
+ }));
10872
+ console.warn(`[canvas-renderer][asset-wait-timeout] elapsed=${elapsed}ms ${summary}`);
10873
+ console.warn("[canvas-renderer][asset-wait-timeout][dom-images]", domImageDebug);
10874
+ console.warn("[canvas-renderer][asset-wait-timeout][fabric-images]", fabricImageDebug);
10772
10875
  settle();
10773
10876
  return;
10774
10877
  }
@@ -10922,6 +11025,169 @@ class PixldocsRenderer {
10922
11025
  );
10923
11026
  });
10924
11027
  }
11028
+ // ─── Internal: capture SVG from a rendered Fabric canvas ───
11029
+ captureSvgViaPreviewCanvas(config, pageIndex, canvasWidth, canvasHeight) {
11030
+ return new Promise(async (resolve, reject) => {
11031
+ const { PreviewCanvas: PreviewCanvas2 } = await Promise.resolve().then(() => PreviewCanvas$1);
11032
+ const container = document.createElement("div");
11033
+ container.style.cssText = `
11034
+ position: fixed; left: -99999px; top: -99999px;
11035
+ width: ${canvasWidth}px; height: ${canvasHeight}px;
11036
+ overflow: hidden; pointer-events: none; opacity: 0;
11037
+ `;
11038
+ document.body.appendChild(container);
11039
+ const timeout = setTimeout(() => {
11040
+ cleanup();
11041
+ reject(new Error("SVG render timeout (30s)"));
11042
+ }, 3e4);
11043
+ const cleanup = () => {
11044
+ clearTimeout(timeout);
11045
+ try {
11046
+ root.unmount();
11047
+ } catch {
11048
+ }
11049
+ container.remove();
11050
+ };
11051
+ const onReady = () => {
11052
+ const expectedImageCount = this.getExpectedImageCount(config, pageIndex);
11053
+ this.waitForCanvasImages(container, expectedImageCount).then(() => {
11054
+ var _a, _b;
11055
+ try {
11056
+ const fabricInstance = this.getFabricCanvasFromContainer(container);
11057
+ if (!fabricInstance) {
11058
+ cleanup();
11059
+ reject(new Error("No Fabric canvas instance found for SVG capture"));
11060
+ return;
11061
+ }
11062
+ const prevVPT = fabricInstance.viewportTransform ? [...fabricInstance.viewportTransform] : void 0;
11063
+ const prevSvgVPT = fabricInstance.svgViewportTransformation;
11064
+ fabricInstance.viewportTransform = [1, 0, 0, 1, 0, 0];
11065
+ fabricInstance.svgViewportTransformation = false;
11066
+ const svgString = fabricInstance.toSVG();
11067
+ if (prevVPT) fabricInstance.viewportTransform = prevVPT;
11068
+ fabricInstance.svgViewportTransformation = prevSvgVPT;
11069
+ const page = config.pages[pageIndex];
11070
+ const backgroundColor = ((_a = page == null ? void 0 : page.settings) == null ? void 0 : _a.backgroundColor) || "#ffffff";
11071
+ const backgroundGradient = (_b = page == null ? void 0 : page.settings) == null ? void 0 : _b.backgroundGradient;
11072
+ cleanup();
11073
+ resolve({
11074
+ svg: svgString,
11075
+ width: canvasWidth,
11076
+ height: canvasHeight,
11077
+ backgroundColor,
11078
+ backgroundGradient
11079
+ });
11080
+ } catch (err) {
11081
+ cleanup();
11082
+ reject(err);
11083
+ }
11084
+ });
11085
+ };
11086
+ const root = createRoot(container);
11087
+ root.render(
11088
+ createElement(PreviewCanvas2, {
11089
+ config,
11090
+ pageIndex,
11091
+ zoom: 1,
11092
+ // 1:1 — no scaling for SVG capture
11093
+ absoluteZoom: true,
11094
+ onReady
11095
+ })
11096
+ );
11097
+ });
11098
+ }
11099
+ /**
11100
+ * Find the Fabric.Canvas instance that belongs to a given container element,
11101
+ * using the global __fabricCanvasRegistry (set by PageCanvas).
11102
+ */
11103
+ getFabricCanvasFromContainer(container) {
11104
+ const registry2 = window.__fabricCanvasRegistry;
11105
+ if (registry2 instanceof Map) {
11106
+ for (const entry of registry2.values()) {
11107
+ const canvas = (entry == null ? void 0 : entry.canvas) || entry;
11108
+ if (!canvas || typeof canvas.toSVG !== "function") continue;
11109
+ const el = canvas.lowerCanvasEl || canvas.upperCanvasEl;
11110
+ if (el && container.contains(el)) return canvas;
11111
+ }
11112
+ }
11113
+ return null;
11114
+ }
11115
+ }
11116
+ function collectImageUrls(config) {
11117
+ const urls = [];
11118
+ const walk = (nodes) => {
11119
+ for (const node of nodes) {
11120
+ if (!node || node.visible === false) continue;
11121
+ const src = typeof node.src === "string" ? node.src.trim() : "";
11122
+ const imageUrl = typeof node.imageUrl === "string" ? node.imageUrl.trim() : "";
11123
+ if (node.type === "image") {
11124
+ const url = src || imageUrl;
11125
+ if (url) urls.push(url);
11126
+ }
11127
+ if (Array.isArray(node.children) && node.children.length > 0) {
11128
+ walk(node.children);
11129
+ }
11130
+ }
11131
+ };
11132
+ for (const page of config.pages || []) {
11133
+ walk(page.children || []);
11134
+ }
11135
+ return urls;
11136
+ }
11137
+ function normalizeAssetUrl(rawUrl, imageProxyUrl) {
11138
+ if (!rawUrl) return null;
11139
+ if (rawUrl.startsWith("data:") || rawUrl.startsWith("blob:")) return null;
11140
+ try {
11141
+ const h = new URL(rawUrl).hostname.toLowerCase();
11142
+ if (h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h.endsWith(".local") || /^(10\.|192\.168\.|169\.254\.)/.test(h)) {
11143
+ return null;
11144
+ }
11145
+ } catch {
11146
+ return null;
11147
+ }
11148
+ const supabaseUrl = typeof globalThis.__VITE_SUPABASE_URL === "string" ? globalThis.__VITE_SUPABASE_URL : "";
11149
+ if (supabaseUrl && rawUrl.includes(supabaseUrl)) {
11150
+ const signedMatch = rawUrl.match(/\/storage\/v1\/object\/sign\/([^?]+)/);
11151
+ if (signedMatch) return `${supabaseUrl}/storage/v1/object/public/${signedMatch[1]}`;
11152
+ if (rawUrl.includes("/storage/v1/object/public/")) return rawUrl;
11153
+ }
11154
+ const proxyBase = imageProxyUrl ? imageProxyUrl.replace(/\/image-proxy(?:\?.*)?$/, "") : API_URL;
11155
+ if (proxyBase) {
11156
+ return `${proxyBase}/image-proxy?url=${encodeURIComponent(rawUrl)}`;
11157
+ }
11158
+ return rawUrl;
11159
+ }
11160
+ const CONCURRENCY = 6;
11161
+ async function prefetchUrls(urls, signal) {
11162
+ const unique = [...new Set(urls)];
11163
+ if (unique.length === 0) return;
11164
+ let i = 0;
11165
+ const next = async () => {
11166
+ while (i < unique.length) {
11167
+ if (signal == null ? void 0 : signal.aborted) return;
11168
+ const url = unique[i++];
11169
+ try {
11170
+ await fetch(url, { signal, mode: "cors", credentials: "omit" });
11171
+ } catch {
11172
+ }
11173
+ }
11174
+ };
11175
+ const workers = Array.from({ length: Math.min(CONCURRENCY, unique.length) }, () => next());
11176
+ await Promise.all(workers);
11177
+ }
11178
+ async function warmResolvedTemplateForPreview(config, options) {
11179
+ const { signal, imageProxyUrl } = options ?? {};
11180
+ await ensureFontsForResolvedConfig(config);
11181
+ if (signal == null ? void 0 : signal.aborted) return;
11182
+ const rawUrls = collectImageUrls(config);
11183
+ const resolvedUrls = rawUrls.map((u) => normalizeAssetUrl(u, imageProxyUrl)).filter((u) => u !== null);
11184
+ await prefetchUrls(resolvedUrls, signal);
11185
+ }
11186
+ async function warmTemplateFromForm(options) {
11187
+ const { signal, imageProxyUrl, ...resolveOpts } = options;
11188
+ const resolved = await resolveFromForm(resolveOpts);
11189
+ if (signal == null ? void 0 : signal.aborted) return;
11190
+ await warmResolvedTemplateForPreview(resolved.config, { signal, imageProxyUrl });
10925
11191
  }
10926
11192
  export {
10927
11193
  PixldocsPreview,
@@ -10929,10 +11195,13 @@ export {
10929
11195
  applyThemeToConfig,
10930
11196
  collectFontDescriptorsFromConfig,
10931
11197
  collectFontsFromConfig,
11198
+ collectImageUrls,
10932
11199
  ensureFontsForResolvedConfig,
10933
11200
  loadGoogleFontCSS,
10934
11201
  normalizeFontFamily,
10935
11202
  resolveFromForm,
10936
- resolveTemplateData
11203
+ resolveTemplateData,
11204
+ warmResolvedTemplateForPreview,
11205
+ warmTemplateFromForm
10937
11206
  };
10938
11207
  //# sourceMappingURL=index.js.map