@lightdash/cli 3.150.0 → 3.151.0

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.
@@ -6,6 +6,8 @@ Every PDF or printable report app includes a visible Download PDF button — the
6
6
 
7
7
  PDF downloads are image-based: they preserve the visible report exactly, but the exported text is not selectable/searchable. Keep `window.print()` only as a secondary Print action if useful; the Download PDF button should save a file directly.
8
8
 
9
+ Capture pages as JPEG (`toJpeg`, quality 0.92, white background), never PNG — a PNG page is ~10MB and a multi-page report blows past email attachment limits; JPEG keeps a 5-page report around 1-2MB with no visible quality loss on reports.
10
+
9
11
  Rules:
10
12
 
11
13
  - Render each PDF page or section in a stable DOM container, e.g. `.pdf-page`, with fixed printable dimensions or aspect ratio.
@@ -13,13 +15,15 @@ Rules:
13
15
  - Track `isExportingPdf`, disable the button while report data is loading or PDF generation is running, and show a spinner or "Exporting..." label until the promise settles.
14
16
  - Use chart value labels in PDF reports because exported pages cannot be hovered.
15
17
  - Avoid capturing scroll containers with hidden content. Capture page-sized elements that already contain the full content intended for export.
18
+ - Register the same builder for scheduled deliveries with `registerPdfExport` from `@/lib/pdfExport` — see below. Without it, scheduled PDF deliveries of this app fail.
16
19
 
17
20
  ```tsx
18
21
  import { Button } from '@/components/ui/button';
19
- import { toPng } from 'html-to-image';
22
+ import { registerPdfExport } from '@/lib/pdfExport';
23
+ import { toJpeg } from 'html-to-image';
20
24
  import { jsPDF } from 'jspdf';
21
25
  import { Download, Loader2 } from 'lucide-react';
22
- import { useRef, useState } from 'react';
26
+ import { useEffect, useRef, useState } from 'react';
23
27
 
24
28
  async function imageLoaded(src: string) {
25
29
  const image = new Image();
@@ -31,10 +35,7 @@ async function imageLoaded(src: string) {
31
35
  return image;
32
36
  }
33
37
 
34
- async function downloadPdfFromPages(
35
- pages: HTMLElement[],
36
- filename = 'report.pdf',
37
- ) {
38
+ async function buildPdfFromPages(pages: HTMLElement[]) {
38
39
  const pdf = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'a4' });
39
40
  const pageWidth = pdf.internal.pageSize.getWidth();
40
41
  const pageHeight = pdf.internal.pageSize.getHeight();
@@ -42,9 +43,10 @@ async function downloadPdfFromPages(
42
43
  for (let index = 0; index < pages.length; index += 1) {
43
44
  if (index > 0) pdf.addPage();
44
45
 
45
- const dataUrl = await toPng(pages[index], {
46
+ const dataUrl = await toJpeg(pages[index], {
46
47
  cacheBust: true,
47
48
  pixelRatio: 2,
49
+ quality: 0.92,
48
50
  backgroundColor: '#ffffff',
49
51
  });
50
52
  const image = await imageLoaded(dataUrl);
@@ -52,9 +54,22 @@ async function downloadPdfFromPages(
52
54
  const width = image.width * scale;
53
55
  const height = image.height * scale;
54
56
 
55
- pdf.addImage(dataUrl, 'PNG', (pageWidth - width) / 2, 0, width, height);
57
+ pdf.addImage(dataUrl, 'JPEG', (pageWidth - width) / 2, 0, width, height);
56
58
  }
57
59
 
60
+ return pdf;
61
+ }
62
+
63
+ function reportPages(root: HTMLElement) {
64
+ const pages = Array.from(root.querySelectorAll<HTMLElement>('.pdf-page'));
65
+ return pages.length > 0 ? pages : [root];
66
+ }
67
+
68
+ async function downloadPdfFromPages(
69
+ pages: HTMLElement[],
70
+ filename = 'report.pdf',
71
+ ) {
72
+ const pdf = await buildPdfFromPages(pages);
58
73
  pdf.save(filename.endsWith('.pdf') ? filename : `${filename}.pdf`);
59
74
  }
60
75
 
@@ -62,15 +77,22 @@ export function PdfReport() {
62
77
  const reportRef = useRef<HTMLDivElement | null>(null);
63
78
  const [isExportingPdf, setIsExportingPdf] = useState(false);
64
79
 
80
+ // Scheduled deliveries reuse the exact builder behind the button; the
81
+ // host requests the Blob once the app has rendered with its data.
82
+ useEffect(() => {
83
+ registerPdfExport(async () => {
84
+ if (!reportRef.current) throw new Error('Report not mounted');
85
+ const pdf = await buildPdfFromPages(reportPages(reportRef.current));
86
+ return pdf.output('blob');
87
+ });
88
+ }, []);
89
+
65
90
  async function exportPdf() {
66
91
  if (!reportRef.current) return;
67
92
  setIsExportingPdf(true);
68
93
  try {
69
- const pages = Array.from(
70
- reportRef.current.querySelectorAll<HTMLElement>('.pdf-page'),
71
- );
72
94
  await downloadPdfFromPages(
73
- pages.length > 0 ? pages : [reportRef.current],
95
+ reportPages(reportRef.current),
74
96
  'executive-report.pdf',
75
97
  );
76
98
  } finally {
@@ -522,7 +522,9 @@ When the user asks for "Open in Google Sheets" (or any Sheets destination), read
522
522
 
523
523
  A PDF or printable report app always includes a visible **Download PDF** button — the export action is part of the report shape, not an optional extra, and `window.print()` is only ever a secondary Print action. This applies whenever the app is report-shaped, whatever the request's wording: the PDF Report starter template, a "printable" / "document" / "report to share" ask, or an app that already renders `.pdf-page` sections. On edit turns, keep the existing Download PDF button working — an edit that removes it is a regression.
524
524
 
525
- Before wiring the button, read `/app/references/pdf-downloads.md` — it has the required `html-to-image` + `jspdf` pattern and page-capture rules.
525
+ Every app with a Download PDF button also registers the same builder for scheduled deliveries via `registerPdfExport` from `@/lib/pdfExport` (builder resolves to `pdf.output('blob')`). The button and the registration always ship together — an app with one but not the other is incomplete, on first generation and after every edit.
526
+
527
+ Before wiring the button, read `/app/references/pdf-downloads.md` — it has the required `html-to-image` + `jspdf` pattern, page-capture rules, and the `registerPdfExport` wiring.
526
528
 
527
529
  ### Underlying data
528
530
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Lets the host (scheduled deliveries, headless renders) request the same
3
+ * PDF the app's Download PDF button produces. The app registers a builder
4
+ * that resolves to a Blob; the host asks for it over the sandbox bridge.
5
+ *
6
+ * Mirrors the screenshot handshake in `screenshotHandler.js`: availability
7
+ * is announced so the parent knows the app is report-shaped, and only the
8
+ * finished Blob crosses the sandbox boundary — never HTML. The announce
9
+ * happens on register (not boot) because only apps with a PDF report have
10
+ * anything to export; hosts must therefore listen before the app mounts.
11
+ */
12
+
13
+ const REQUEST_TYPE = 'lightdash:sdk:pdf-export-request';
14
+ const RESPONSE_TYPE = 'lightdash:sdk:pdf-export-response';
15
+ const AVAILABLE_TYPE = 'lightdash:sdk:pdf-export-available';
16
+
17
+ let exporter = null;
18
+
19
+ /**
20
+ * Register the app's PDF builder. Call once on app mount with a function
21
+ * that builds the report PDF from the current DOM and resolves to a Blob
22
+ * (`pdf.output('blob')`) — the same builder the Download PDF button uses,
23
+ * minus the `.save()`. Re-registering replaces the previous builder.
24
+ */
25
+ export function registerPdfExport(buildPdfBlob) {
26
+ exporter = typeof buildPdfBlob === 'function' ? buildPdfBlob : null;
27
+ if (exporter && typeof window !== 'undefined') {
28
+ window.parent.postMessage({ type: AVAILABLE_TYPE }, '*');
29
+ }
30
+ }
31
+
32
+ export function initPdfExportHandler() {
33
+ if (typeof window === 'undefined') return;
34
+
35
+ window.addEventListener('message', async (event) => {
36
+ const { data } = event;
37
+ if (data?.type !== REQUEST_TYPE) return;
38
+
39
+ const { id } = data;
40
+ try {
41
+ if (!exporter) {
42
+ throw new Error('This app has no PDF export registered');
43
+ }
44
+ const blob = await exporter();
45
+ if (!(blob instanceof Blob)) {
46
+ throw new Error('PDF exporter must resolve to a Blob');
47
+ }
48
+ window.parent.postMessage({ type: RESPONSE_TYPE, id, blob }, '*');
49
+ } catch (err) {
50
+ window.parent.postMessage(
51
+ {
52
+ type: RESPONSE_TYPE,
53
+ id,
54
+ error:
55
+ err instanceof Error ? err.message : 'PDF export failed',
56
+ },
57
+ '*',
58
+ );
59
+ }
60
+ });
61
+ }
@@ -19,6 +19,7 @@ import { ErrorBoundary } from '@/lib/ErrorBoundary';
19
19
  import './index.css';
20
20
  import './chart-overrides.css';
21
21
  import App from './App';
22
+ import { initPdfExportHandler } from '@/lib/pdfExport';
22
23
  import initScreenshotHandler from './screenshotHandler';
23
24
 
24
25
  const queryClient = new QueryClient({
@@ -33,6 +34,7 @@ const queryClient = new QueryClient({
33
34
  const lightdash = createClient();
34
35
 
35
36
  initScreenshotHandler();
37
+ initPdfExportHandler();
36
38
 
37
39
  // A chart type renders in a frame the size of its tile; a page app grows.
38
40
  // Only a chart type receives a viz context, so mark the document for index.css.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "3.150.0",
3
+ "version": "3.151.0",
4
4
  "description": "Lightdash CLI tool",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  "@actions/core": "1.11.1",
23
23
  "@aws-sdk/credential-providers": "3.995.0",
24
24
  "@casl/ability": "6.8.0",
25
- "@lightdash/common": "3.150.0",
26
- "@lightdash/warehouses": "3.150.0",
25
+ "@lightdash/common": "3.151.0",
26
+ "@lightdash/warehouses": "3.151.0",
27
27
  "@sidvind/better-ajv-errors": "7.0.0",
28
28
  "@types/columnify": "1.5.1",
29
29
  "ajv": "8.18.0",