Skip to content

Commit 0132fc9

Browse files
authored
Feat: FAQ export to PDF (#178)
* WIP on pdf * wip * Correct TOC and no empty pages * Fix formatting of text in paragraph * Fix heading style * Fix TOC placement * TOc minor fix * Add image support * Handle md list * Finish up * Increase heading size * Fix build?
1 parent dc92336 commit 0132fc9

18 files changed

+1720
-4
lines changed

bun.lockb

7.84 KB
Binary file not shown.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
"firebase": "^10.5.2",
4141
"flag-icons": "^7.3.2",
4242
"image-blob-reduce": "^4.1.0",
43+
"jspdf": "^3.0.1",
4344
"luxon": "^3.4.3",
45+
"marked": "^15.0.8",
4446
"openai": "^4.24.7",
4547
"p-limit": "^5.0.0",
4648
"pica": "^9.0.1",

src/events/page/faq/FaqCategoryItem.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Event, Faq, FaqCategory } from '../../../types'
33
import { useFaq } from '../../../services/hooks/useFaq'
44
import { FirestoreQueryLoaderAndErrorDisplay } from '../../../components/FirestoreQueryLoaderAndErrorDisplay'
55
import { Box, Button, IconButton, Typography } from '@mui/material'
6-
import { ExpandLessSharp, ExpandMore } from '@mui/icons-material'
6+
import { ExpandLessSharp, ExpandMore, PictureAsPdf } from '@mui/icons-material'
77
import { LoadingButton } from '@mui/lab'
88
import {
99
useFirestoreCollectionMutation,
@@ -13,6 +13,7 @@ import { collections } from '../../../services/firebase'
1313
import { collection } from '@firebase/firestore'
1414
import { generateFirestoreId } from '../../../utils/generateFirestoreId'
1515
import { FaqCategoryItemContent } from './FaqCategoryItemContent'
16+
import { generateFaqPdf } from '../../../utils/faqPdfGenerator'
1617

1718
export type FaqCategoryProps = {
1819
event: Event
@@ -30,6 +31,7 @@ export const FaqCategoryItem = (props: FaqCategoryProps) => {
3031
const deletionMutation = useFirestoreDocumentDeletion(
3132
collection(collections.faq(props.event.id), categoryId, 'items')
3233
)
34+
const [isExporting, setIsExporting] = useState(false)
3335

3436
useEffect(() => {
3537
if (queryResult.loaded) {
@@ -73,20 +75,38 @@ export const FaqCategoryItem = (props: FaqCategoryProps) => {
7375
setDidChange(false)
7476
}
7577

78+
const exportToPdf = async () => {
79+
try {
80+
setIsExporting(true)
81+
await generateFaqPdf(props.event, props.category, data)
82+
} catch (error) {
83+
console.error('Failed to generate PDF:', error)
84+
} finally {
85+
setIsExporting(false)
86+
}
87+
}
88+
7689
return (
7790
<Box width="100%" mb={4}>
78-
<Box display="flex">
79-
<Typography variant="h4" justifyContent="space-between" alignItems="center" marginBottom={1}>
91+
<Box display="flex" justifyContent="space-between" alignItems="center">
92+
<Typography variant="h4" marginBottom={1}>
8093
{props.category.name} ({data.length})
94+
</Typography>
95+
<Box>
8196
<IconButton onClick={() => setOpen(!isOpen)}>
8297
{isOpen ? <ExpandLessSharp /> : <ExpandMore />}
8398
</IconButton>
99+
{isOpen && (
100+
<IconButton onClick={exportToPdf} title="Export to PDF" disabled={isExporting}>
101+
<PictureAsPdf />
102+
</IconButton>
103+
)}
84104
{didChange && (
85105
<LoadingButton variant="contained" onClick={save} loading={mutation.isLoading}>
86106
Save
87107
</LoadingButton>
88108
)}
89-
</Typography>
109+
</Box>
90110
</Box>
91111
{isOpen ? (
92112
<>

src/types/html2pdf.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
declare module 'html2pdf.js' {
2+
interface Html2PdfOptions {
3+
margin?: number
4+
filename?: string
5+
image?: {
6+
type?: string
7+
quality?: number
8+
}
9+
html2canvas?: {
10+
scale?: number
11+
}
12+
jsPDF?: {
13+
unit?: string
14+
format?: string
15+
orientation?: string
16+
}
17+
}
18+
19+
interface Html2PdfInstance {
20+
set(options: Html2PdfOptions): Html2PdfInstance
21+
from(element: HTMLElement): Html2PdfInstance
22+
save(): void
23+
}
24+
25+
const html2pdf: () => Html2PdfInstance
26+
export default html2pdf
27+
}

src/utils/faqPdfGenerator.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Event, Faq, FaqCategory } from '../types'
2+
import { markdownToPdf } from './markdownToPdf/markdownToPdf'
3+
4+
type FaqWithoutTimestamps = Omit<Faq, 'updatedAt' | 'createdAt'>
5+
6+
export const generateFaqPdf = async (event: Event, category: FaqCategory, faqs: FaqWithoutTimestamps[]) => {
7+
// Generate markdown content
8+
const markdownContent = generateMarkdownContent(event, category, faqs)
9+
10+
// Convert markdown to PDF
11+
const pdfBlob = await markdownToPdf(markdownContent)
12+
13+
// Create download link and trigger download
14+
const url = URL.createObjectURL(pdfBlob)
15+
const a = document.createElement('a')
16+
a.href = url
17+
a.download = `${event.name}-${category.name}-FAQ.pdf`
18+
document.body.appendChild(a)
19+
a.click()
20+
document.body.removeChild(a)
21+
URL.revokeObjectURL(url)
22+
}
23+
24+
const generateMarkdownContent = (event: Event, category: FaqCategory, faqs: FaqWithoutTimestamps[]): string => {
25+
// Generate main content
26+
const content = faqs
27+
.map(
28+
(faq, index) => `
29+
## ${faq.question}
30+
31+
${faq.answer}
32+
`
33+
)
34+
.join('\n\n')
35+
36+
return `# ${event.name} - ${category.name} FAQ
37+
38+
[TOC]
39+
40+
${content}
41+
`
42+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { jsPDF } from 'jspdf'
2+
import type { Token } from 'marked'
3+
import { LinkAnnotation, Margins } from '../types'
4+
import { checkAddPage } from '../pageUtils'
5+
6+
export const handleBlockquote = (
7+
doc: jsPDF,
8+
token: Token & { type: 'blockquote'; text: string },
9+
margins: Margins,
10+
currentY: number,
11+
currentPage: number,
12+
maxLineWidth: number
13+
): { newY: number; newPage: number; linkAnnotations: LinkAnnotation[] } => {
14+
const lineHeight = 5
15+
let pageCheck = checkAddPage(doc, currentY, lineHeight + 4, margins, currentPage)
16+
17+
doc.setDrawColor(200)
18+
doc.setLineWidth(0.5)
19+
doc.line(
20+
margins.left,
21+
pageCheck.newY - lineHeight / 2,
22+
margins.left,
23+
pageCheck.newY + lineHeight * doc.splitTextToSize(token.text, maxLineWidth - 5).length
24+
)
25+
26+
doc.setFont('helvetica', 'italic')
27+
doc.setFontSize(10)
28+
const quoteLines = doc.splitTextToSize(token.text, maxLineWidth - 5)
29+
30+
let currentLineY = pageCheck.newY
31+
let currentPageNum = pageCheck.newPage
32+
const linkAnnotations: LinkAnnotation[] = []
33+
34+
quoteLines.forEach((line: string) => {
35+
pageCheck = checkAddPage(doc, currentLineY, lineHeight, margins, currentPageNum)
36+
currentLineY = pageCheck.newY
37+
currentPageNum = pageCheck.newPage
38+
39+
doc.text(line, margins.left + 5, currentLineY)
40+
currentLineY += lineHeight
41+
})
42+
43+
doc.setFont('helvetica', 'normal')
44+
45+
return {
46+
newY: currentLineY + 4,
47+
newPage: currentPageNum,
48+
linkAnnotations,
49+
}
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { jsPDF } from 'jspdf'
2+
import type { Token } from 'marked'
3+
import { LinkAnnotation, Margins } from '../types'
4+
import { checkAddPage } from '../pageUtils'
5+
6+
export const handleCode = (
7+
doc: jsPDF,
8+
token: Token & { type: 'code'; text: string },
9+
margins: Margins,
10+
currentY: number,
11+
currentPage: number,
12+
maxLineWidth: number
13+
): { newY: number; newPage: number; linkAnnotations: LinkAnnotation[] } => {
14+
const lineHeight = 4.5
15+
const codeLines = token.text.split('\n')
16+
const codeBlockHeight = codeLines.length * lineHeight + 2
17+
18+
let pageCheck = checkAddPage(doc, currentY, codeBlockHeight + 4, margins, currentPage)
19+
20+
doc.setFont('courier', 'normal')
21+
doc.setFontSize(9)
22+
doc.setFillColor(240, 240, 240)
23+
24+
doc.rect(margins.left, pageCheck.newY - lineHeight + 1, maxLineWidth, codeBlockHeight, 'F')
25+
26+
let currentLineY = pageCheck.newY
27+
let currentPageNum = pageCheck.newPage
28+
const linkAnnotations: LinkAnnotation[] = []
29+
30+
codeLines.forEach((line: string) => {
31+
pageCheck = checkAddPage(doc, currentLineY, lineHeight, margins, currentPageNum)
32+
currentLineY = pageCheck.newY
33+
currentPageNum = pageCheck.newPage
34+
35+
const displayLine = doc.splitTextToSize(line, maxLineWidth - 4)[0] || ''
36+
doc.text(displayLine, margins.left + 2, currentLineY)
37+
currentLineY += lineHeight
38+
})
39+
40+
doc.setFont('helvetica', 'normal')
41+
doc.setFontSize(10)
42+
43+
return {
44+
newY: currentLineY + 4,
45+
newPage: currentPageNum,
46+
linkAnnotations,
47+
}
48+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { jsPDF } from 'jspdf'
2+
import type { Token } from 'marked'
3+
import { TocEntry, DestinationAnnotation, Margins } from '../types'
4+
import { checkAddPage } from '../pageUtils'
5+
import { parseInlineContent } from './inlineContentParser'
6+
7+
export const handleHeading = (
8+
doc: jsPDF,
9+
token: Token & { type: 'heading'; depth: number; text: string; tokens?: Token[] },
10+
margins: Margins,
11+
currentY: number,
12+
currentPage: number,
13+
tocEntries: TocEntry[]
14+
): { newY: number; newPage: number; destinationAnnotation: DestinationAnnotation } => {
15+
const fontSize = Math.max(12, 34 - token.depth * 6)
16+
// Estimate line height based on font size for page check, actual height might vary with wrapping
17+
const estimatedLineHeight = fontSize / 2
18+
const pageCheck = checkAddPage(doc, currentY, estimatedLineHeight + 2, margins, currentPage)
19+
let yPos = pageCheck.newY
20+
const newPage = pageCheck.newPage
21+
22+
doc.setFont('helvetica', 'bold') // Default heading style
23+
doc.setFontSize(fontSize)
24+
// Use the plain text for TOC
25+
tocEntries.push({ text: token.text, level: token.depth, page: newPage, y: yPos })
26+
27+
// Use the inline content parser to render styled text
28+
const finalY = parseInlineContent(
29+
doc,
30+
token.tokens || [{ type: 'text', text: token.text, raw: token.text }],
31+
margins.left,
32+
yPos,
33+
margins
34+
)
35+
36+
doc.setFont('helvetica', 'normal') // Reset font style after heading
37+
doc.setFontSize(10)
38+
39+
return {
40+
newY: finalY + 7, // Add 7 points padding after the heading baseline
41+
newPage: newPage,
42+
destinationAnnotation: {
43+
page: newPage,
44+
y: yPos, // Use the initial y position for the link target
45+
headingText: token.text,
46+
headingLevel: token.depth,
47+
},
48+
}
49+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { jsPDF } from 'jspdf'
2+
import { LinkAnnotation, Margins } from '../types'
3+
import { checkAddPage } from '../pageUtils'
4+
5+
export const handleHr = (
6+
doc: jsPDF,
7+
margins: Margins,
8+
currentY: number,
9+
currentPage: number,
10+
maxLineWidth: number
11+
): { newY: number; newPage: number; linkAnnotations: LinkAnnotation[] } => {
12+
const lineHeight = 5
13+
const pageCheck = checkAddPage(doc, currentY, lineHeight, margins, currentPage)
14+
15+
doc.setLineWidth(0.5)
16+
doc.line(margins.left, pageCheck.newY, margins.left + maxLineWidth, pageCheck.newY)
17+
18+
return {
19+
newY: pageCheck.newY + lineHeight,
20+
newPage: pageCheck.newPage,
21+
linkAnnotations: [],
22+
}
23+
}

0 commit comments

Comments
 (0)