Files
Joshen Lim 38fa40e851 Joshenlim/fe 4445 explorer export as pdf option 2 (#50786)
## Context

Another take on [this
PR](https://github.com/supabase/supabase/pull/50733), decided that it's
better UX + DX to use a PDF library (`@react-pdf/renderer`) instead for
handling exporting Notebooks to PDF. The previous method using `print`
involved a lot of sparse patching in various places + had a lot of
limitations, not to mention its awkward UX to "Print".

There's a lot of code changes in this PR but it's because we're
constructing the PDF document based on the notebook's contents using
primitives from `@react-pdf/renderer` which gives us a lot more control
over the PDF's output.

PDF output will differ slightly from the Notebook UI in the browser
- Added a custom header in the report which covers the notebook name +
project name, and when its exported
- Empty markdown cells are skipped
- Query cells will show both SQL content + results (either table or
chart)
- Opting to skip syntax highlighting for SQL content (Can consider
separately)
- Results will only be included in the PDF if the notebook's been run
(Aligns with Copy as Markdown CTA)
- For table results, _all_ rows will be rendered (Aligns with Copy as
Markdown CTA)

Feel free to give it a spin! 🙂 🙏 

<img width="248" height="207" alt="image"
src="https://github.com/user-attachments/assets/e38c14ba-7ee8-40ce-99b1-fa126453bf0f"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added an “Export as PDF” option for notebooks.
- PDFs include notebook details, Markdown content, SQL queries, query
results, tables, and charts.
- Query exports show errors, empty results, row counts, and applicable
row limits.
- Export progress is displayed while the PDF is generated, and failures
are reported via notifications.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-24 13:23:38 +08:00

173 lines
5.2 KiB
TypeScript

import { Link as PdfLink, Text, View } from '@react-pdf/renderer'
import type { ListItem, Content as MdastContent, Root as MdastRoot, PhrasingContent } from 'mdast'
import type { ReactElement, ReactNode } from 'react'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { unified } from 'unified'
import { pdfStyles } from './theme'
const HEADING_STYLES = [
pdfStyles.heading1,
pdfStyles.heading2,
pdfStyles.heading3,
pdfStyles.heading4,
pdfStyles.heading5,
pdfStyles.heading6,
]
export function parseMarkdownToMdast(markdown: string): MdastRoot {
return unified().use(remarkParse).use(remarkGfm).parse(markdown) as MdastRoot
}
function mapInline(node: PhrasingContent, key: string): ReactNode {
switch (node.type) {
case 'text':
return node.value
case 'strong':
return (
<Text key={key} style={pdfStyles.bold}>
{node.children.map((child, index) => mapInline(child, `${key}-${index}`))}
</Text>
)
case 'emphasis':
return (
<Text key={key} style={pdfStyles.italic}>
{node.children.map((child, index) => mapInline(child, `${key}-${index}`))}
</Text>
)
case 'delete':
return (
<Text key={key} style={{ textDecoration: 'line-through' }}>
{node.children.map((child, index) => mapInline(child, `${key}-${index}`))}
</Text>
)
case 'inlineCode':
return (
<Text key={key} style={pdfStyles.inlineCode}>
{node.value}
</Text>
)
case 'link':
return (
<PdfLink key={key} src={node.url} style={pdfStyles.link}>
{node.children.map((child, index) => mapInline(child, `${key}-${index}`))}
</PdfLink>
)
case 'break':
return '\n'
default:
return null
}
}
function mapListItem(
item: ListItem,
ordered: boolean,
start: number,
index: number,
key: string
): ReactElement {
const marker = ordered ? `${start + index}.` : '•'
return (
<View key={key} style={pdfStyles.listItem}>
<Text style={pdfStyles.listItemMarker}>{marker}</Text>
<View style={{ flex: 1 }}>
{item.children.map((child, childIndex) =>
mapBlock(child, `${key}-${childIndex}`, { tight: true })
)}
</View>
</View>
)
}
function mapBlock(
node: MdastContent,
key: string,
{ tight = false }: { tight?: boolean } = {}
): ReactElement | null {
switch (node.type) {
case 'heading':
return (
<Text key={key} style={HEADING_STYLES[Math.min(node.depth, 6) - 1]}>
{node.children.map((child, index) => mapInline(child, `${key}-${index}`))}
</Text>
)
case 'paragraph':
return (
<Text
key={key}
style={tight ? [pdfStyles.paragraph, { marginBottom: 0 }] : pdfStyles.paragraph}
>
{node.children.map((child, index) => mapInline(child, `${key}-${index}`))}
</Text>
)
case 'list': {
const ordered = node.ordered ?? false
const start = node.start ?? 1
return (
<View key={key} style={pdfStyles.list}>
{node.children.map((item, index) =>
mapListItem(item, ordered, start, index, `${key}-${index}`)
)}
</View>
)
}
case 'blockquote':
return (
<View key={key} style={pdfStyles.blockquote}>
{node.children.map((child, index) => mapBlock(child, `${key}-${index}`))}
</View>
)
case 'thematicBreak':
return <View key={key} style={pdfStyles.thematicBreak} />
case 'code':
return (
<View key={key} style={pdfStyles.sqlBox}>
<Text style={pdfStyles.sqlText}>{node.value}</Text>
</View>
)
case 'table': {
const [headerRow, ...bodyRows] = node.children
return (
<View key={key} style={pdfStyles.table}>
<View style={pdfStyles.tableHeaderRow}>
{headerRow.children.map((cell, cellIndex) => (
<Text key={`${key}-header-${cellIndex}`} style={pdfStyles.tableHeaderCell}>
{cell.children.map((child, childIndex) =>
mapInline(child, `${key}-header-${cellIndex}-${childIndex}`)
)}
</Text>
))}
</View>
{bodyRows.map((row, rowIndex) => (
<View key={`${key}-row-${rowIndex}`} style={pdfStyles.tableRow}>
{row.children.map((cell, cellIndex) => (
<Text key={`${key}-row-${rowIndex}-${cellIndex}`} style={pdfStyles.tableCell}>
{cell.children.map((child, childIndex) =>
mapInline(child, `${key}-row-${rowIndex}-${cellIndex}-${childIndex}`)
)}
</Text>
))}
</View>
))}
</View>
)
}
default:
return null
}
}
export function markdownToPdfBlocks(markdown: string): ReactElement[] {
const root = parseMarkdownToMdast(markdown)
return root.children
.map((child, index) => mapBlock(child, `md-${index}`))
.filter((element): element is ReactElement => element !== null)
}
export function NotebookPdfMarkdown({ markdown }: { markdown: string }) {
return <View style={pdfStyles.cellSpacing}>{markdownToPdfBlocks(markdown)}</View>
}