diff --git a/package-lock.json b/package-lock.json index d18f5b7eb96f..95f6b027d3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11108,6 +11108,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -12625,6 +12633,19 @@ "dev": true, "license": "MIT" }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -12913,6 +12934,17 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -13306,6 +13338,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -13592,6 +13637,27 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -14851,6 +14917,17 @@ "node": ">=4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -19604,6 +19681,21 @@ "node": ">=12" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -21114,6 +21206,33 @@ "node": "*" } }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf/node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/katex": { "version": "0.16.44", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", @@ -25460,6 +25579,14 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -28257,6 +28384,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -28861,6 +28999,14 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/regex": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", @@ -29480,6 +29626,17 @@ "dev": true, "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "dev": true, + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -30863,6 +31020,17 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -31713,6 +31881,17 @@ "dev": true, "license": "MIT" }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/svgo": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", @@ -32249,6 +32428,17 @@ "b4a": "^1.6.4" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -33572,6 +33762,17 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -35805,6 +36006,7 @@ "html-to-image": "^1.11.13", "jsdom": "^26.0.0", "json-schema": "^0.4.0", + "jspdf": "^2.5.2", "lucide-svelte": "^0.298.0", "luxon": "^3.5.0", "marked": "^16.4.0", diff --git a/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte b/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte index 520d5ccb97b8..221bf70ae577 100644 --- a/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte +++ b/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte @@ -17,12 +17,29 @@ import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte"; import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; + import ExportDashboardForm from "@rilldata/web-common/features/exports/pdf/ExportDashboardForm.svelte"; + import { exportCanvasPdf } from "@rilldata/web-common/features/exports/pdf/export-canvas-pdf"; + import type { PdfExportRunOptions } from "@rilldata/web-common/features/exports/pdf/types"; export let createMagicAuthTokens: boolean; + // Provide canvas identifiers to enable the "PDF" tab (canvas dashboards only). + export let canvasName: string | undefined = undefined; + export let instanceId: string | undefined = undefined; const { hidePublicUrl } = featureFlags; let isOpen = false; let copied = false; + let runPdfExport: ((o: PdfExportRunOptions) => Promise) | null = null; + + // Bind the (now-narrowed) identifiers in a helper so the returned closure keeps + // them as `string` rather than `string | undefined`. + $: runPdfExport = + canvasName && instanceId ? makeRunPdfExport(canvasName, instanceId) : null; + + function makeRunPdfExport(name: string, id: string) { + return (o: PdfExportRunOptions) => + exportCanvasPdf({ canvasName: name, instanceId: id, ...o }); + } function onCopy() { navigator.clipboard.writeText(window.location.href).catch(console.error); @@ -50,6 +67,9 @@ {#if createMagicAuthTokens && !$hidePublicUrl} Create public URL {/if} + {#if runPdfExport} + PDF + {/if}
@@ -77,6 +97,14 @@ {/if} + {#if runPdfExport} + + (isOpen = false)} + /> + + {/if} diff --git a/web-admin/src/features/projects/ProjectHeader.svelte b/web-admin/src/features/projects/ProjectHeader.svelte index bd3b5b85cbee..6dda7e2f3ea4 100644 --- a/web-admin/src/features/projects/ProjectHeader.svelte +++ b/web-admin/src/features/projects/ProjectHeader.svelte @@ -288,6 +288,8 @@ {/if} {/if} diff --git a/web-common/package.json b/web-common/package.json index 5463152b988b..bb6c1aafe2e7 100644 --- a/web-common/package.json +++ b/web-common/package.json @@ -76,6 +76,7 @@ "html-to-image": "^1.11.13", "jsdom": "^26.0.0", "json-schema": "^0.4.0", + "jspdf": "^2.5.2", "lucide-svelte": "^0.298.0", "luxon": "^3.5.0", "marked": "^16.4.0", diff --git a/web-common/src/features/canvas/CanvasComponent.svelte b/web-common/src/features/canvas/CanvasComponent.svelte index db1f9f34729c..00971aadddb2 100644 --- a/web-common/src/features/canvas/CanvasComponent.svelte +++ b/web-common/src/features/canvas/CanvasComponent.svelte @@ -9,16 +9,24 @@ @@ -18,7 +19,7 @@ class:flex-col={col} class:flex-wrap={!col} class="flex gap-y-2 gap-x-2 w-full flex-none" - aria-label="Readonly Filter Chips" + aria-label={ariaLabel} >
{#if timeRangeString} diff --git a/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte b/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte new file mode 100644 index 000000000000..83055e782248 --- /dev/null +++ b/web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte @@ -0,0 +1,76 @@ + + +
+ {#if formattedTimeRange} +
+ {formattedTimeRange} + {#if formattedComparisonRange} + vs {formattedComparisonRange} + {/if} + · {$timeZoneStore} +
+ {/if} + + {#if hasFilters} + + {/if} +
diff --git a/web-common/src/features/exports/pdf/CanvasPdfExportView.svelte b/web-common/src/features/exports/pdf/CanvasPdfExportView.svelte new file mode 100644 index 000000000000..4a11a783165a --- /dev/null +++ b/web-common/src/features/exports/pdf/CanvasPdfExportView.svelte @@ -0,0 +1,55 @@ + + +
+ + +
+ {#each rows as row, rowIndex (rowIndex)} + + {/each} +
+
+ + diff --git a/web-common/src/features/exports/pdf/ExportDashboardForm.svelte b/web-common/src/features/exports/pdf/ExportDashboardForm.svelte new file mode 100644 index 000000000000..8c091da05491 --- /dev/null +++ b/web-common/src/features/exports/pdf/ExportDashboardForm.svelte @@ -0,0 +1,81 @@ + + +
+

+ Export this dashboard as a PDF. +

+ + + + +
+ + diff --git a/web-common/src/features/exports/pdf/assemble.spec.ts b/web-common/src/features/exports/pdf/assemble.spec.ts new file mode 100644 index 000000000000..27b2aacc74c8 --- /dev/null +++ b/web-common/src/features/exports/pdf/assemble.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { parseColor } from "./assemble"; + +describe("parseColor", () => { + it("parses rgb and rgba colors", () => { + expect(parseColor("rgb(31, 35, 41)")).toEqual({ r: 31, g: 35, b: 41 }); + expect(parseColor("rgba(31, 35, 41, 1)")).toEqual({ + r: 31, + g: 35, + b: 41, + }); + }); + + it("treats transparent backgrounds as white", () => { + expect(parseColor("rgba(0, 0, 0, 0)")).toEqual({ + r: 255, + g: 255, + b: 255, + }); + }); + + it("parses modern CSS colors used by theme tokens", () => { + expect(parseColor("oklch(24.3% 0 0)")).toEqual({ + r: 32, + g: 32, + b: 32, + }); + expect(parseColor("color(srgb 0.1 0.2 0.3)")).toEqual({ + r: 26, + g: 51, + b: 77, + }); + }); +}); diff --git a/web-common/src/features/exports/pdf/assemble.ts b/web-common/src/features/exports/pdf/assemble.ts new file mode 100644 index 000000000000..030ebb1a6cf5 --- /dev/null +++ b/web-common/src/features/exports/pdf/assemble.ts @@ -0,0 +1,292 @@ +import { jsPDF } from "jspdf"; +import chroma from "chroma-js"; +import type { PaginationResult, Placement } from "./layout"; + +export interface AssembleMeta { + title: string; + filename: string; + backgroundColor: string; + generatedAt: string; + dashboardUrl: string; +} + +interface RGB { + r: number; + g: number; + b: number; +} + +const TITLE_FONT_SIZE_PT = 13; +// Vertical space the title header reserves at the top of the first page: the +// title text plus a gap before the content. Passed to paginate() as titleReservePt. +export const TITLE_BAND_PT = 30; + +// PDF chrome (title, footer) colors, pulled from the app's theme tokens so the +// export matches the UI. The RGB fallbacks apply when the variable can't be +// resolved (e.g. in a non-browser test environment). +const TITLE_COLOR = { + cssVar: "var(--fg-primary)", + fallback: { r: 31, g: 35, b: 41 }, +}; +const FOOTER_TEXT_COLOR = { + cssVar: "var(--fg-muted)", + fallback: { r: 120, g: 120, b: 120 }, +}; +const FOOTER_LINK_COLOR = { + cssVar: "var(--color-primary-600)", + fallback: { r: 37, g: 99, b: 235 }, +}; + +// Renders the paginated placements into a PDF and triggers a download. +export async function assemblePdf( + result: PaginationResult, + meta: AssembleMeta, +): Promise { + const doc = new jsPDF({ + unit: "pt", + format: [result.pageWidthPt, result.pageHeightPt], + orientation: result.orientation, + }); + doc.setProperties({ title: meta.title }); + + const background = parseColor(meta.backgroundColor) ?? { + r: 255, + g: 255, + b: 255, + }; + const imageCache = new Map(); + + for (let page = 0; page < result.pageCount; page++) { + if (page > 0) { + doc.addPage( + [result.pageWidthPt, result.pageHeightPt], + result.orientation, + ); + } + + doc.setFillColor(background.r, background.g, background.b); + doc.rect(0, 0, result.pageWidthPt, result.pageHeightPt, "F"); + + if (page === 0) { + drawTitle(doc, result, meta); + } + + for (const placement of result.placements) { + if (placement.page !== page) continue; + await drawPlacement(doc, placement, imageCache); + } + + drawFooter(doc, result, meta); + } + + doc.save(meta.filename); +} + +// Draws the dashboard title as vector text in the band reserved at the top of +// the first page (see TITLE_BAND_PT). Truncated with an ellipsis if it would +// overflow the content width. +function drawTitle( + doc: jsPDF, + result: PaginationResult, + meta: AssembleMeta, +): void { + if (!meta.title) return; + + doc.setFont("helvetica", "bold"); + doc.setFontSize(TITLE_FONT_SIZE_PT); + setTextColor(doc, TITLE_COLOR); + + const maxWidthPt = result.pageWidthPt - 2 * result.marginPt; + let title = meta.title; + if (doc.getTextWidth(title) > maxWidthPt) { + while (title.length > 1 && doc.getTextWidth(`${title}…`) > maxWidthPt) { + title = title.slice(0, -1); + } + title = `${title}…`; + } + + // Baseline near the bottom of the reserved band, leaving a gap before content. + doc.text(title, result.marginPt, result.marginPt + TITLE_FONT_SIZE_PT); + doc.setFont("helvetica", "normal"); +} + +function drawFooter( + doc: jsPDF, + result: PaginationResult, + meta: AssembleMeta, +): void { + const yPt = result.pageHeightPt - 10; + const generatedText = `Generated ${meta.generatedAt}`; + const linkPrefix = "Open the live dashboard: "; + const linkText = "View in Rill"; + + doc.setFontSize(8); + setTextColor(doc, FOOTER_TEXT_COLOR); + doc.text(generatedText, result.marginPt, yPt); + + const linkXPt = + result.pageWidthPt - + result.marginPt - + doc.getTextWidth(`${linkPrefix}${linkText}`); + doc.text(linkPrefix, linkXPt, yPt); + setTextColor(doc, FOOTER_LINK_COLOR); + doc.textWithLink(linkText, linkXPt + doc.getTextWidth(linkPrefix), yPt, { + url: meta.dashboardUrl, + }); +} + +// Resolves a theme color token to RGB and applies it as the document's text +// color, falling back to a fixed RGB when the token can't be resolved (e.g. in +// a non-browser test environment). +function setTextColor( + doc: jsPDF, + { cssVar, fallback }: { cssVar: string; fallback: RGB }, +): void { + const { r, g, b } = parseColor(resolveThemeColor(cssVar)) ?? fallback; + doc.setTextColor(r, g, b); +} + +// Reads a CSS custom property off the document root, following up to a couple of +// levels of var() indirection (e.g. --fg-primary -> var(--color-neutral-950) -> +// an actual color). Returns the input unchanged outside a browser. +function resolveThemeColor(cssVar: string): string { + if (typeof window === "undefined") return cssVar; + let value = cssVar; + for (let i = 0; i < 3 && value.startsWith("var("); i++) { + const varName = value.slice(4, value.lastIndexOf(")")).split(",")[0].trim(); + const resolved = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + if (!resolved) return value; + value = resolved; + } + return value; +} + +async function drawPlacement( + doc: jsPDF, + placement: Placement, + imageCache: Map, +): Promise { + let dataUrl = placement.block.dataUrl; + + // Sliced blocks: crop the source image to the requested vertical band. + if (placement.srcHeightPx !== undefined && placement.srcYPx !== undefined) { + const img = await loadImage(placement.block.dataUrl, imageCache); + const ratio = img.naturalHeight / placement.block.heightPx; + dataUrl = cropImage( + img, + placement.srcYPx * ratio, + placement.srcHeightPx * ratio, + ); + } + + doc.addImage( + dataUrl, + "JPEG", + placement.xPt, + placement.yPt, + placement.wPt, + placement.hPt, + ); +} + +function loadImage( + dataUrl: string, + cache: Map, +): Promise { + const cached = cache.get(dataUrl); + if (cached) return Promise.resolve(cached); + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + cache.set(dataUrl, img); + resolve(img); + }; + img.onerror = reject; + img.src = dataUrl; + }); +} + +function cropImage( + img: HTMLImageElement, + srcY: number, + srcHeight: number, +): string { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = Math.round(srcHeight); + const ctx = canvas.getContext("2d"); + if (!ctx) return img.src; + ctx.drawImage( + img, + 0, + srcY, + img.naturalWidth, + srcHeight, + 0, + 0, + img.naturalWidth, + srcHeight, + ); + // Source blocks are already opaque JPEGs, so re-encoding as JPEG is safe and + // keeps sliced pages as small as the rest of the document. + return canvas.toDataURL("image/jpeg", 0.85); +} + +export function parseColor(color: string): RGB | null { + const trimmed = color.trim(); + + const rgb = parseRgbColor(trimmed); + if (rgb) return rgb; + + const srgb = parseSrgbColor(trimmed); + if (srgb) return srgb; + + try { + const [r, g, b] = chroma(trimmed).rgb(); + return { r, g, b }; + } catch { + return null; + } +} + +function parseRgbColor(color: string): RGB | null { + const match = color.match(/rgba?\(([^)]+)\)/); + if (!match) return null; + const parts = match[1] + .split(/[,\s/]+/) + .filter(Boolean) + .map((p) => parseFloat(p.trim())); + if (parts.length < 3) return null; + // Treat a fully transparent background as white (the canvas paints white). + if (parts.length >= 4 && parts[3] === 0) return { r: 255, g: 255, b: 255 }; + return { r: parts[0], g: parts[1], b: parts[2] }; +} + +function parseSrgbColor(color: string): RGB | null { + const match = color.match(/^color\(\s*srgb\s+([^)]+)\)$/i); + if (!match) return null; + + const parts = match[1].split(/[,\s/]+/).filter(Boolean); + if (parts.length < 3) return null; + + const alpha = parts[3] ? parseCssUnit(parts[3]) : 1; + if (alpha === 0) return { r: 255, g: 255, b: 255 }; + + return { + r: toByte(parseCssUnit(parts[0])), + g: toByte(parseCssUnit(parts[1])), + b: toByte(parseCssUnit(parts[2])), + }; +} + +function parseCssUnit(value: string): number { + if (value.endsWith("%")) return parseFloat(value) / 100; + return parseFloat(value); +} + +function toByte(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.round(Math.min(1, Math.max(0, value)) * 255); +} diff --git a/web-common/src/features/exports/pdf/capture.spec.ts b/web-common/src/features/exports/pdf/capture.spec.ts new file mode 100644 index 000000000000..bec9559beb39 --- /dev/null +++ b/web-common/src/features/exports/pdf/capture.spec.ts @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { inlineSvgStyles } from "./capture"; + +describe("inlineSvgStyles", () => { + it("restores original SVG style attributes", () => { + const root = document.createElement("div"); + root.innerHTML = ` + + + + + `; + + const svg = root.querySelector("svg")!; + const path = root.querySelector("path")!; + const circle = root.querySelector("circle")!; + + const restore = inlineSvgStyles(root); + expect(svg.getAttribute("style")).not.toBe("color: red"); + expect(path.getAttribute("style")).not.toBe("stroke-width: 2"); + expect(circle.hasAttribute("style")).toBe(true); + + restore(); + expect(svg.getAttribute("style")).toBe("color: red"); + expect(path.getAttribute("style")).toBe("stroke-width: 2"); + expect(circle.hasAttribute("style")).toBe(false); + }); +}); diff --git a/web-common/src/features/exports/pdf/capture.ts b/web-common/src/features/exports/pdf/capture.ts new file mode 100644 index 000000000000..dd1c05df17dd --- /dev/null +++ b/web-common/src/features/exports/pdf/capture.ts @@ -0,0 +1,183 @@ +import { toJpeg } from "html-to-image"; +import { + FILTER_BAR_ID, + FILTER_BAR_ROW_INDEX, + type CapturedBlock, +} from "./types"; + +// Properties that don't reliably serialize from subtrees during cloning, +// so we pin their computed values inline before capture. Mirrors the approach in +// time-series/ScreenshotContainer.svelte. +const SVG_PROPS = [ + "fill", + "fill-opacity", + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + "opacity", + "font-family", + "font-size", + "font-weight", + "color", +]; + +export function inlineSvgStyles(root: HTMLElement): () => void { + const previousStyles: Array<{ el: Element; style: string | null }> = []; + root.querySelectorAll("svg, svg *").forEach((el) => { + const cs = getComputedStyle(el); + const inline = SVG_PROPS.map((p) => `${p}: ${cs.getPropertyValue(p)}`).join( + "; ", + ); + previousStyles.push({ el, style: el.getAttribute("style") }); + el.setAttribute("style", `${inline}; ${el.getAttribute("style") ?? ""}`); + }); + + return () => { + for (const { el, style } of previousStyles) { + if (style === null) el.removeAttribute("style"); + else el.setAttribute("style", style); + } + }; +} + +const PIXEL_RATIO = 2; +// JPEG keeps PDFs an order of magnitude smaller than lossless PNG while staying +// crisp for dashboard charts/text. JPEG has no alpha, so we supply a background. +const JPEG_QUALITY = 0.85; + +// Rasterizes a single element to a JPEG data URL. +export async function rasterizeNode( + node: HTMLElement, + backgroundColor: string, +): Promise { + const restoreSvgStyles = inlineSvgStyles(node); + try { + return await toJpeg(node, { + cacheBust: true, + pixelRatio: PIXEL_RATIO, + quality: JPEG_QUALITY, + backgroundColor, + }); + } finally { + restoreSvgStyles(); + } +} + +export interface CaptureResult { + blocks: CapturedBlock[]; + contentWidthPx: number; + backgroundColor: string; +} + +export interface CaptureOptions { + instanceId: string; + canvasName: string; + includeFilters: boolean; + onProgress?: (ratio: number) => void; +} + +// Rasterizes the filter bar (optional) and each canvas component into image +// blocks positioned relative to the canvas content area. Per-block failures +// degrade to a skipped block rather than aborting the whole export. +export async function captureCanvasBlocks( + opts: CaptureOptions, +): Promise { + // The off-screen export render (see CanvasPdfExportView), mounted only while + // exporting. Capturing a dedicated tree keeps the live dashboard untouched. + // Scope the lookup to this canvas store (keyed by instance + canvas name) so a + // second export view (if another is mounted on the page) can't be captured by + // mistake. + const exportView = Array.from( + document.querySelectorAll("#canvas-pdf-export-view"), + ).find( + (el) => + el.dataset.instanceId === opts.instanceId && + el.dataset.canvasName === opts.canvasName, + ); + const rowContainer = exportView?.querySelector(".row-container"); + + if (!exportView || !rowContainer) { + throw new Error( + "Canvas content is not available to export. Make sure all required filters are set.", + ); + } + + const contentRect = rowContainer.getBoundingClientRect(); + const contentWidthPx = rowContainer.clientWidth; + const backgroundColor = getComputedStyle(exportView).backgroundColor; + + const articles = Array.from( + rowContainer.querySelectorAll("article.component-card"), + ); + + const blocks: CapturedBlock[] = []; + const total = articles.length + (opts.includeFilters ? 1 : 0); + let done = 0; + const reportProgress = () => opts.onProgress?.(total ? done / total : 1); + + if (opts.includeFilters) { + // Read-only summary block (title + exact time range + filter chips), + // rendered inside the export view specifically for capture; see + // CanvasPdfExportHeader. + const header = exportView.querySelector( + "#canvas-pdf-export-header", + ); + if (header) { + // Match the header's width to the content area so it scales consistently + // with the component blocks during pagination. + header.style.width = `${contentWidthPx}px`; + if (header.scrollHeight > 0) { + try { + const dataUrl = await rasterizeNode(header, backgroundColor); + blocks.push({ + id: FILTER_BAR_ID, + dataUrl, + xPx: 0, + yPx: 0, + widthPx: contentWidthPx, + heightPx: header.scrollHeight, + rowIndex: FILTER_BAR_ROW_INDEX, + }); + } catch (e) { + console.warn("Failed to capture canvas header for PDF export", e); + } + } + } + done += 1; + reportProgress(); + } + + for (const article of articles) { + const rect = article.getBoundingClientRect(); + try { + const dataUrl = await rasterizeNode(article, backgroundColor); + blocks.push({ + id: article.id, + dataUrl, + xPx: rect.left - contentRect.left, + yPx: rect.top - contentRect.top, + widthPx: rect.width, + heightPx: rect.height, + rowIndex: rowIndexFor(article, rowContainer), + }); + } catch (e) { + console.warn(`Failed to capture canvas component "${article.id}"`, e); + } + done += 1; + reportProgress(); + } + + return { blocks, contentWidthPx, backgroundColor }; +} + +// Canvas rows are
elements; use the section's DOM order as the row +// index so components in the same row are grouped and laid out together. +function rowIndexFor(article: HTMLElement, rowContainer: HTMLElement): number { + const section = article.closest("section"); + if (!section) return 0; + const sections = Array.from(rowContainer.querySelectorAll("section")); + const index = sections.indexOf(section); + return index === -1 ? 0 : index; +} diff --git a/web-common/src/features/exports/pdf/export-canvas-pdf.ts b/web-common/src/features/exports/pdf/export-canvas-pdf.ts new file mode 100644 index 000000000000..aa182189497a --- /dev/null +++ b/web-common/src/features/exports/pdf/export-canvas-pdf.ts @@ -0,0 +1,72 @@ +import { get } from "svelte/store"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; +import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; +import { assemblePdf, TITLE_BAND_PT } from "./assemble"; +import { captureCanvasBlocks } from "./capture"; +import { buildPdfFilename } from "./filename"; +import { paginate } from "./layout"; +import { prepareCanvasForCapture } from "./settle"; +import { + DEFAULT_PDF_ORIENTATION, + DEFAULT_PDF_PAGE_FORMAT, + type ExportCanvasPdfOptions, +} from "./types"; + +// Orchestrates a client-side canvas-to-PDF export: mount the off-screen export +// render (via exportMode), rasterize the filter bar + each component, paginate +// to mirror the on-screen layout, then assemble and download the PDF. Stateless; +// the caller owns UI state (loading flag, notifications). +export async function exportCanvasPdf( + opts: ExportCanvasPdfOptions, +): Promise { + const { canvasEntity } = getCanvasStore(opts.canvasName, opts.instanceId); + + // Mount the off-screen export render (see CanvasPdfExportView) and force-enable + // every component's data query; the tick() inside prepareCanvasForCapture + // flushes it into the DOM before we capture. The live dashboard is untouched. + canvasEntity.exportMode.set(true); + + try { + opts.onProgress?.({ phase: "preparing", ratio: 0 }); + await prepareCanvasForCapture(canvasEntity, queryClient, { + instanceId: opts.instanceId, + timeoutMs: opts.timeoutMs, + }); + opts.onProgress?.({ phase: "preparing", ratio: 1 }); + + const { blocks, contentWidthPx, backgroundColor } = + await captureCanvasBlocks({ + instanceId: opts.instanceId, + canvasName: opts.canvasName, + includeFilters: opts.includeFilters, + onProgress: (ratio) => opts.onProgress?.({ phase: "capturing", ratio }), + }); + + if (!blocks.length) { + throw new Error("Nothing to export on this canvas."); + } + + const title = get(canvasEntity.titleStore) || opts.canvasName; + + opts.onProgress?.({ phase: "assembling", ratio: 0 }); + const pagination = paginate(blocks, { + contentWidthPx, + format: DEFAULT_PDF_PAGE_FORMAT, + orientation: DEFAULT_PDF_ORIENTATION, + titleReservePt: title ? TITLE_BAND_PT : 0, + }); + + // UTC, e.g. "2026-06-19 02:20 UTC". + const generatedAt = `${new Date().toISOString().replace("T", " ").slice(0, 16)} UTC`; + await assemblePdf(pagination, { + title, + filename: buildPdfFilename(title), + backgroundColor, + generatedAt, + dashboardUrl: window.location.href, + }); + opts.onProgress?.({ phase: "assembling", ratio: 1 }); + } finally { + canvasEntity.exportMode.set(false); + } +} diff --git a/web-common/src/features/exports/pdf/filename.ts b/web-common/src/features/exports/pdf/filename.ts new file mode 100644 index 000000000000..4c5e0afc4743 --- /dev/null +++ b/web-common/src/features/exports/pdf/filename.ts @@ -0,0 +1,16 @@ +// Builds a download filename from a dashboard title plus a local timestamp, +// e.g. "sales-overview-20260619-130412.pdf". Shared by the canvas and explore +// PDF export orchestrators. +export function buildPdfFilename(title: string): string { + const slug = + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "dashboard"; + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + return `${slug}-${stamp}.pdf`; +} diff --git a/web-common/src/features/exports/pdf/layout.spec.ts b/web-common/src/features/exports/pdf/layout.spec.ts new file mode 100644 index 000000000000..6d59c0e7be7e --- /dev/null +++ b/web-common/src/features/exports/pdf/layout.spec.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { paginate, resolveOrientation } from "./layout"; +import type { CapturedBlock } from "./types"; + +function block( + partial: Partial & { id: string }, +): CapturedBlock { + return { + dataUrl: "data:image/png;base64,xxx", + xPx: 0, + yPx: 0, + widthPx: 1000, + heightPx: 200, + rowIndex: 0, + ...partial, + }; +} + +// A4 portrait content area: 841.89 - 48 ≈ 793.89pt tall, 595.28 - 48 ≈ 547.28pt wide. +const A4 = { format: "a4" as const, orientation: "portrait" as const }; + +describe("resolveOrientation", () => { + it("keeps explicit orientations", () => { + expect(resolveOrientation("portrait", 2000)).toBe("portrait"); + expect(resolveOrientation("landscape", 100)).toBe("landscape"); + }); + + it("auto picks landscape for wide canvases, portrait otherwise", () => { + expect(resolveOrientation("auto", 1200)).toBe("landscape"); + expect(resolveOrientation("auto", 600)).toBe("portrait"); + }); +}); + +describe("paginate", () => { + it("places a single short row on one page, scaled to content width", () => { + const result = paginate( + [block({ id: "a", widthPx: 1000, heightPx: 200 })], + { + ...A4, + contentWidthPx: 1000, + }, + ); + expect(result.pageCount).toBe(1); + expect(result.orientation).toBe("portrait"); + const p = result.placements[0]; + // Scaled to the full content width. + expect(p.wPt).toBeCloseTo(result.pageWidthPt - 2 * result.marginPt, 1); + expect(p.page).toBe(0); + expect(p.xPt).toBeCloseTo(result.marginPt, 1); + expect(p.yPt).toBeCloseTo(result.marginPt, 1); + }); + + it("keeps two columns of one row on the same page side by side", () => { + const result = paginate( + [ + block({ id: "left", xPx: 0, widthPx: 500, heightPx: 300, rowIndex: 0 }), + block({ + id: "right", + xPx: 500, + widthPx: 500, + heightPx: 300, + rowIndex: 0, + }), + ], + { ...A4, contentWidthPx: 1000 }, + ); + expect(result.pageCount).toBe(1); + const [left, right] = result.placements; + expect(left.page).toBe(0); + expect(right.page).toBe(0); + expect(right.xPt).toBeGreaterThan(left.xPt); + }); + + it("paginates multiple rows without splitting a component across pages", () => { + // Each row fits within one page, so rows move page-by-page without slicing + // individual components. + const blocks = [0, 1, 2, 3].map((i) => + block({ id: `r${i}`, rowIndex: i, widthPx: 1000, heightPx: 900 }), + ); + const result = paginate(blocks, { ...A4, contentWidthPx: 1000 }); + // No placement should be a slice. + expect(result.placements.every((p) => p.srcHeightPx === undefined)).toBe( + true, + ); + // Each block appears exactly once. + expect(result.placements).toHaveLength(4); + // More than one page used. + expect(result.pageCount).toBeGreaterThan(1); + // Every block fits within a single page (height <= content height). + const contentHeight = result.pageHeightPt - 2 * result.marginPt; + for (const p of result.placements) { + expect(p.hPt).toBeLessThanOrEqual(contentHeight + 0.5); + expect(p.yPt + p.hPt).toBeLessThanOrEqual( + result.pageHeightPt - result.marginPt + 0.5, + ); + } + }); + + it("slices a single component taller than a full page across pages", () => { + // 5000px tall at scale ~0.547 => ~2735pt, content height ~794pt => 4 slices. + const result = paginate( + [block({ id: "tall-table", widthPx: 1000, heightPx: 5000 })], + { ...A4, contentWidthPx: 1000 }, + ); + const slices = result.placements.filter((p) => p.block.id === "tall-table"); + expect(slices.length).toBeGreaterThan(1); + // Slices have complementary, non-overlapping source crops covering the image. + const sorted = [...slices].sort( + (a, b) => (a.srcYPx ?? 0) - (b.srcYPx ?? 0), + ); + let covered = 0; + for (const s of sorted) { + expect(s.srcYPx).toBeCloseTo(covered, 0); + covered += s.srcHeightPx ?? 0; + } + expect(covered).toBeCloseTo(5000, 0); + // Each slice lives on its own page. + expect(new Set(slices.map((s) => s.page)).size).toBe(slices.length); + }); + + it("slices every component in a multi-component row taller than a full page", () => { + const result = paginate( + [ + block({ + id: "left-table", + xPx: 0, + widthPx: 500, + heightPx: 5000, + rowIndex: 0, + }), + block({ + id: "right-table", + xPx: 500, + widthPx: 500, + heightPx: 5000, + rowIndex: 0, + }), + ], + { ...A4, contentWidthPx: 1000 }, + ); + + const leftSlices = result.placements.filter( + (p) => p.block.id === "left-table", + ); + const rightSlices = result.placements.filter( + (p) => p.block.id === "right-table", + ); + expect(leftSlices.length).toBeGreaterThan(1); + expect(leftSlices).toHaveLength(rightSlices.length); + expect(new Set(leftSlices.map((s) => s.page))).toEqual( + new Set(rightSlices.map((s) => s.page)), + ); + for (const placement of result.placements) { + expect(placement.srcHeightPx).toBeDefined(); + expect(placement.yPt + placement.hPt).toBeLessThanOrEqual( + result.pageHeightPt - result.marginPt + 0.5, + ); + } + }); + + it("offsets the first page's content by titleReservePt", () => { + const withTitle = paginate( + [block({ id: "a", widthPx: 1000, heightPx: 200 })], + { ...A4, contentWidthPx: 1000, titleReservePt: 30 }, + ); + // The first block starts one title band below the margin. + expect(withTitle.placements[0].yPt).toBeCloseTo(withTitle.marginPt + 30, 1); + }); + + it("places the first slice of a tall component on page 0 below the title band", () => { + // A component taller than a full page must still begin on page 0, not strand + // the title on an otherwise-empty first page. + const result = paginate( + [block({ id: "tall", widthPx: 1000, heightPx: 5000 })], + { ...A4, contentWidthPx: 1000, titleReservePt: 30 }, + ); + const slices = result.placements + .filter((p) => p.block.id === "tall") + .sort((a, b) => a.page - b.page); + expect(slices[0].page).toBe(0); + // The first slice starts below the title band. + expect(slices[0].yPt).toBeCloseTo(result.marginPt + 30, 1); + }); + + it("does not strand the title when the first row fits a page but not the title band", () => { + // A row that fills nearly a full page (just under content height) would + // overflow once the title band is reserved; it must still land on page 0. + const contentHeightPx = (841.89 - 48) / ((595.28 - 48) / 1000); + const result = paginate( + [ + block({ + id: "near-full", + widthPx: 1000, + heightPx: Math.round(contentHeightPx) - 10, + }), + ], + { ...A4, contentWidthPx: 1000, titleReservePt: 30 }, + ); + // Page 0 must carry content, not just the title. + expect(result.placements.some((p) => p.page === 0)).toBe(true); + }); + + it("places the filter bar (rowIndex -1) before content rows", () => { + const result = paginate( + [ + block({ id: "comp", rowIndex: 0, heightPx: 200 }), + block({ id: "__filter_bar__", rowIndex: -1, heightPx: 80 }), + ], + { ...A4, contentWidthPx: 1000 }, + ); + const filter = result.placements.find( + (p) => p.block.id === "__filter_bar__", + )!; + const comp = result.placements.find((p) => p.block.id === "comp")!; + expect(filter.yPt).toBeLessThan(comp.yPt); + }); +}); diff --git a/web-common/src/features/exports/pdf/layout.ts b/web-common/src/features/exports/pdf/layout.ts new file mode 100644 index 000000000000..45761f3403f6 --- /dev/null +++ b/web-common/src/features/exports/pdf/layout.ts @@ -0,0 +1,200 @@ +import type { + CapturedBlock, + PdfPageFormat, + PdfOrientation, + ResolvedOrientation, +} from "./types"; + +// Page dimensions in PostScript points (1/72 inch), portrait orientation. +// jsPDF measures in points by default, so these feed directly into it. +const PAGE_SIZES_PT: Record = + { + a4: { width: 595.28, height: 841.89 }, + letter: { width: 612, height: 792 }, + }; + +const DEFAULT_MARGIN_PT = 24; +// Vertical gap between canvas rows, in points. +const ROW_GAP_PT = 12; +// A canvas wider than this (in CSS px) is exported as landscape under "auto". +const AUTO_LANDSCAPE_WIDTH_PX = 900; + +export interface PaginateOptions { + contentWidthPx: number; + format: PdfPageFormat; + orientation: PdfOrientation; + marginPt?: number; + // Vertical space reserved at the top of the first page for the title header + // (drawn by assemblePdf). Subsequent pages start at the margin. + titleReservePt?: number; +} + +export interface Placement { + block: CapturedBlock; + // 0-based page index. + page: number; + xPt: number; + yPt: number; + wPt: number; + hPt: number; + // For blocks sliced across pages: the source crop within the image, in image + // pixels. Undefined when the whole block is drawn. + srcYPx?: number; + srcHeightPx?: number; +} + +export interface PaginationResult { + pageWidthPt: number; + pageHeightPt: number; + marginPt: number; + pageCount: number; + orientation: ResolvedOrientation; + placements: Placement[]; +} + +export function resolveOrientation( + orientation: PdfOrientation, + contentWidthPx: number, +): ResolvedOrientation { + if (orientation === "auto") { + return contentWidthPx > AUTO_LANDSCAPE_WIDTH_PX ? "landscape" : "portrait"; + } + return orientation; +} + +// Groups blocks into canvas rows (preserving DOM order within a row) and walks +// them top-to-bottom, scaling the on-screen layout to the page content width. +// A row that would overflow the current page moves wholesale to the next page; +// a single-block row taller than a full page is sliced across pages. +export function paginate( + blocks: CapturedBlock[], + opts: PaginateOptions, +): PaginationResult { + const orientation = resolveOrientation(opts.orientation, opts.contentWidthPx); + const size = PAGE_SIZES_PT[opts.format]; + const pageWidthPt = orientation === "landscape" ? size.height : size.width; + const pageHeightPt = orientation === "landscape" ? size.width : size.height; + const marginPt = opts.marginPt ?? DEFAULT_MARGIN_PT; + + const contentWidthPt = pageWidthPt - 2 * marginPt; + const contentHeightPt = pageHeightPt - 2 * marginPt; + const scale = + opts.contentWidthPx > 0 ? contentWidthPt / opts.contentWidthPx : 1; + + const rows = groupIntoRows(blocks); + + // The title header occupies the top of the first page; content starts below + // it. Subsequent pages start at the margin. + const titleReservePt = opts.titleReservePt ?? 0; + const pageTopPt = (p: number) => marginPt + (p === 0 ? titleReservePt : 0); + + const placements: Placement[] = []; + let page = 0; + let cursorYPt = pageTopPt(0); + + for (const row of rows) { + const rowTopPx = Math.min(...row.map((b) => b.yPx)); + const rowHeightPt = Math.max(...row.map((b) => b.heightPx)) * scale; + + // True when nothing has been placed on the current page yet, so we must not + // advance to a fresh page (that would strand the page, e.g. page 0 holding + // only the title band). + const atPageTop = cursorYPt <= pageTopPt(page) + 0.5; + const remainingPt = pageHeightPt - marginPt - cursorYPt; + + // A row must be sliced when it can't fit a fresh page here: either it is + // taller than a full page, or it is the first thing on this page and still + // overflows (the title band leaves too little room on page 0). For + // multi-component rows, slice every block against the same row-height bands + // so horizontal layout is preserved across pages. + if ( + rowHeightPt > contentHeightPt || + (atPageTop && rowHeightPt > remainingPt + 0.5) + ) { + if (!atPageTop) { + page += 1; + cursorYPt = pageTopPt(page); + } + + const rowHeightPx = rowHeightPt / scale; + let rowSrcYPx = 0; + while (rowSrcYPx < rowHeightPx - 0.5) { + // The first page of a sliced row may have less room due to the title band. + const pageSrcPx = (pageHeightPt - marginPt - pageTopPt(page)) / scale; + const rowSrcHeightPx = Math.min(pageSrcPx, rowHeightPx - rowSrcYPx); + + for (const block of row) { + const blockTopPx = block.yPx - rowTopPx; + const blockBottomPx = blockTopPx + block.heightPx; + const sliceTopPx = Math.max(blockTopPx, rowSrcYPx); + const sliceBottomPx = Math.min( + blockBottomPx, + rowSrcYPx + rowSrcHeightPx, + ); + const srcHeightPx = sliceBottomPx - sliceTopPx; + if (srcHeightPx <= 0.5) continue; + + placements.push({ + block, + page, + xPt: marginPt + block.xPx * scale, + yPt: pageTopPt(page) + (sliceTopPx - rowSrcYPx) * scale, + wPt: block.widthPx * scale, + hPt: srcHeightPx * scale, + srcYPx: sliceTopPx - blockTopPx, + srcHeightPx, + }); + } + + rowSrcYPx += rowSrcHeightPx; + page += 1; + cursorYPt = pageTopPt(page); + } + continue; + } + + // Move the whole row to the next page if it doesn't fit and isn't already + // at the top of a page. + if (!atPageTop && cursorYPt + rowHeightPt > pageHeightPt - marginPt) { + page += 1; + cursorYPt = pageTopPt(page); + } + + for (const block of row) { + placements.push({ + block, + page, + xPt: marginPt + block.xPx * scale, + yPt: cursorYPt + (block.yPx - rowTopPx) * scale, + wPt: block.widthPx * scale, + hPt: block.heightPx * scale, + }); + } + + cursorYPt += rowHeightPt + ROW_GAP_PT; + } + + return { + pageWidthPt, + pageHeightPt, + marginPt, + pageCount: placements.length + ? Math.max(...placements.map((p) => p.page)) + 1 + : 0, + orientation, + placements, + }; +} + +// Orders blocks by rowIndex, then by horizontal position within the row. +function groupIntoRows(blocks: CapturedBlock[]): CapturedBlock[][] { + const byRow = new Map(); + for (const block of blocks) { + const row = byRow.get(block.rowIndex); + if (row) row.push(block); + else byRow.set(block.rowIndex, [block]); + } + return [...byRow.keys()] + .sort((a, b) => a - b) + .map((rowIndex) => byRow.get(rowIndex)!.sort((a, b) => a.xPx - b.xPx)); +} diff --git a/web-common/src/features/exports/pdf/settle.spec.ts b/web-common/src/features/exports/pdf/settle.spec.ts new file mode 100644 index 000000000000..4d683ed6f0d0 --- /dev/null +++ b/web-common/src/features/exports/pdf/settle.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { Readable } from "svelte/store"; +import { isCanvasExportQuery, waitForStoreValue } from "./settle"; + +describe("waitForStoreValue", () => { + it("handles stores that synchronously reach the target on subscribe", async () => { + let subscriptions = 0; + let unsubscriptions = 0; + const store: Readable = { + subscribe(run) { + subscriptions += 1; + run(subscriptions > 1); + return () => { + unsubscriptions += 1; + }; + }, + }; + + await expect(waitForStoreValue(store, true, 100)).resolves.toBe(true); + expect(subscriptions).toBe(2); + expect(unsubscriptions).toBe(2); + }); +}); + +describe("isCanvasExportQuery", () => { + it("matches query service queries for the active runtime instance", () => { + expect( + isCanvasExportQuery( + ["QueryService", "metricsViewAggregation", "instance-1", {}], + "instance-1", + ), + ).toBe(true); + }); + + it("matches custom chart metrics SQL queries for the active instance", () => { + expect( + isCanvasExportQuery( + ["metrics_sql", "instance-1", "chart", 0, "select 1", "{}"], + "instance-1", + ), + ).toBe(true); + }); + + it("ignores custom chart metrics SQL queries from another instance", () => { + expect( + isCanvasExportQuery( + ["metrics_sql", "instance-2", "chart", 0, "select 1", "{}"], + "instance-1", + ), + ).toBe(false); + }); + + it("ignores unrelated admin and other runtime queries", () => { + expect( + isCanvasExportQuery( + ["AdminService", "getProject", { project: "p" }], + "instance-1", + ), + ).toBe(false); + expect( + isCanvasExportQuery( + ["RuntimeService", "gitStatus", "instance-1", {}], + "instance-1", + ), + ).toBe(false); + expect( + isCanvasExportQuery( + ["QueryService", "metricsViewAggregation", "instance-2", {}], + "instance-1", + ), + ).toBe(false); + }); +}); diff --git a/web-common/src/features/exports/pdf/settle.ts b/web-common/src/features/exports/pdf/settle.ts new file mode 100644 index 000000000000..d7d07b5c5f02 --- /dev/null +++ b/web-common/src/features/exports/pdf/settle.ts @@ -0,0 +1,120 @@ +import type { QueryClient, QueryKey } from "@tanstack/svelte-query"; +import { get, type Readable } from "svelte/store"; +import { tick } from "svelte"; +import type { CanvasEntity } from "@rilldata/web-common/features/canvas/stores/canvas-entity"; + +const DEFAULT_TIMEOUT_MS = 60_000; +// How often waitUntilQueriesIdle polls for in-flight queries. Frame-rate polling +// is needlessly frequent; a coarser interval is plenty to detect idleness. +const QUERY_POLL_INTERVAL_MS = 100; + +function asyncRequestAnimationFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Resolves once the boolean store equals `target`, or after `timeoutMs`. +// Returns true if the value was reached, false on timeout. +export function waitForStoreValue( + store: Readable, + target: boolean, + timeoutMs: number, +): Promise { + if (get(store) === target) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let shouldUnsubscribeAfterSubscribe = false; + let unsub = () => { + shouldUnsubscribeAfterSubscribe = true; + }; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsub(); + resolve(value); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + // subscribe() fires synchronously with the current value; guard against it + // resolving (and calling unsub) before unsub is assigned below. + unsub = store.subscribe((value) => { + if (value === target) finish(true); + }); + if (shouldUnsubscribeAfterSubscribe) unsub(); + }); +} + +// Resolves once there are no in-flight queries for `stablePolls` consecutive +// polls (debouncing the gaps between dependent queries), or on timeout. +export async function waitUntilQueriesIdle( + queryClient: QueryClient, + opts: { instanceId: string; stablePolls?: number; timeoutMs: number }, +): Promise { + const stablePolls = opts.stablePolls ?? 2; + const deadline = Date.now() + opts.timeoutMs; + let stable = 0; + while (Date.now() < deadline) { + const fetching = queryClient.isFetching({ + predicate: (query) => + isCanvasExportQuery(query.queryKey, opts.instanceId), + }); + if (fetching === 0) { + stable += 1; + if (stable >= stablePolls) return true; + } else { + stable = 0; + } + await sleep(QUERY_POLL_INTERVAL_MS); + } + return false; +} + +export function isCanvasExportQuery( + queryKey: QueryKey, + instanceId: string, +): boolean { + const [service] = queryKey; + // Custom-chart keys follow [service, instanceId, name, index, sql, filterKey] + // (see CustomChartRenderer); match this instance so an unrelated instance's + // custom chart can't hold up the export. + if (service === "metrics_sql") return queryKey[1] === instanceId; + if (service !== "QueryService") return false; + // QueryService keys follow [ServiceName, methodName, instanceId, request] + // (see runtime-client/invalidation.ts); match the instance at its fixed index + // rather than scanning the whole key. + return queryKey[2] === instanceId; +} + +// Waits for the off-screen export render, its data, and fonts to settle so the +// DOM is ready to be rasterized. The caller sets canvasEntity.exportMode, which +// mounts CanvasPdfExportView and force-enables every component's data query; +// this only waits for the result. Best-effort: returns even on timeout. +export async function prepareCanvasForCapture( + canvasEntity: CanvasEntity, + queryClient: QueryClient, + opts: { instanceId: string; timeoutMs?: number }, +): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // Flush the export view into the DOM before waiting on its queries. + await tick(); + await asyncRequestAnimationFrame(); + await asyncRequestAnimationFrame(); + + await waitForStoreValue(canvasEntity.firstLoad, false, timeoutMs); + await waitUntilQueriesIdle(queryClient, { + instanceId: opts.instanceId, + timeoutMs, + }); + + if (document.fonts) { + await document.fonts.ready; + } + // Give Vega/canvas renderers a couple of frames to flush their final paint. + await asyncRequestAnimationFrame(); + await asyncRequestAnimationFrame(); +} diff --git a/web-common/src/features/exports/pdf/types.ts b/web-common/src/features/exports/pdf/types.ts new file mode 100644 index 000000000000..01f1ca75aba3 --- /dev/null +++ b/web-common/src/features/exports/pdf/types.ts @@ -0,0 +1,49 @@ +export type PdfPageFormat = "a4" | "letter"; + +// "auto" resolves to landscape for wide canvases and portrait otherwise. +export type PdfOrientation = "portrait" | "landscape" | "auto"; + +export const DEFAULT_PDF_PAGE_FORMAT: PdfPageFormat = "a4"; +export const DEFAULT_PDF_ORIENTATION: PdfOrientation = "auto"; + +// Resolved orientation (after "auto" has been decided). +export type ResolvedOrientation = "portrait" | "landscape"; + +export interface ExportProgress { + phase: "preparing" | "capturing" | "assembling"; + // 0..1 within the current phase. + ratio: number; +} + +// The options collected by the shared ExportDashboardForm. Surface-specific +// orchestrators (canvas, explore) receive these plus their own identifiers. +export interface PdfExportRunOptions { + includeFilters: boolean; + onProgress?: (progress: ExportProgress) => void; +} + +export interface ExportCanvasPdfOptions { + canvasName: string; + instanceId: string; + includeFilters: boolean; + timeoutMs?: number; + onProgress?: (progress: ExportProgress) => void; +} + +// Sentinel rowIndex/id for the filter bar block, which always renders first. +export const FILTER_BAR_ID = "__filter_bar__"; +export const FILTER_BAR_ROW_INDEX = -1; + +// A rasterized block (the filter bar or a single component) plus its position +// and size in the canvas content area, measured in CSS pixels. +export interface CapturedBlock { + id: string; + dataUrl: string; + // Position relative to the content area's top-left, in CSS pixels. + xPx: number; + yPx: number; + widthPx: number; + heightPx: number; + // Components sharing a rowIndex are laid out on the same canvas row. + rowIndex: number; +}