export-pdf.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { toPng } from 'html-to-image'
  2. import jsPDF from 'jspdf'
  3. export interface ExportPDFOptions {
  4. elementId: string
  5. filename?: string
  6. title?: string
  7. subtitle?: string
  8. }
  9. /**
  10. * Create a temporary header element for PDF
  11. */
  12. function createPDFHeader(title: string, subtitle: string): HTMLDivElement {
  13. const header = document.createElement('div')
  14. header.style.cssText = `
  15. padding: 24px 32px;
  16. background: #ffffff;
  17. border-bottom: 1px solid #e5e7eb;
  18. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
  19. `
  20. const titleEl = document.createElement('h1')
  21. titleEl.textContent = title
  22. titleEl.style.cssText = `
  23. margin: 0 0 8px 0;
  24. font-size: 24px;
  25. font-weight: 600;
  26. color: #1f2328;
  27. line-height: 1.3;
  28. `
  29. const subtitleEl = document.createElement('p')
  30. subtitleEl.textContent = subtitle
  31. subtitleEl.style.cssText = `
  32. margin: 0 0 8px 0;
  33. font-size: 14px;
  34. color: #6b7280;
  35. line-height: 1.5;
  36. `
  37. const timestampEl = document.createElement('p')
  38. const timestamp = new Date().toLocaleString('zh-CN', {
  39. year: 'numeric',
  40. month: '2-digit',
  41. day: '2-digit',
  42. hour: '2-digit',
  43. minute: '2-digit',
  44. })
  45. timestampEl.textContent = `生成时间: ${timestamp}`
  46. timestampEl.style.cssText = `
  47. margin: 0;
  48. font-size: 12px;
  49. color: #9ca3af;
  50. line-height: 1.5;
  51. `
  52. header.appendChild(titleEl)
  53. if (subtitle)
  54. header.appendChild(subtitleEl)
  55. header.appendChild(timestampEl)
  56. return header
  57. }
  58. /**
  59. * Export DOM element to PDF
  60. * @param options Export options
  61. */
  62. export async function exportToPDF(options: ExportPDFOptions): Promise<void> {
  63. const {
  64. elementId,
  65. filename = 'report.pdf',
  66. title = 'Performance Report',
  67. subtitle = '',
  68. } = options
  69. try {
  70. const element = document.getElementById(elementId)
  71. if (!element) {
  72. throw new Error(`Element with id "${elementId}" not found`)
  73. }
  74. // Create header element (separate from content)
  75. const header = createPDFHeader(title, subtitle)
  76. header.style.width = `${element.offsetWidth}px`
  77. header.style.position = 'fixed'
  78. header.style.left = '-9999px'
  79. header.style.top = '0'
  80. document.body.appendChild(header)
  81. // Wait a bit for header to render
  82. await new Promise(resolve => setTimeout(resolve, 100))
  83. // Capture header
  84. const headerDataUrl = await toPng(header, {
  85. quality: 0.95,
  86. pixelRatio: 2,
  87. backgroundColor: '#ffffff',
  88. })
  89. // Remove header
  90. document.body.removeChild(header)
  91. // Capture content directly (no cloning to preserve Canvas/SVG)
  92. const contentDataUrl = await toPng(element, {
  93. quality: 0.95,
  94. pixelRatio: 2,
  95. backgroundColor: '#ffffff',
  96. cacheBust: true,
  97. })
  98. // Load header image
  99. const headerImg = new Image()
  100. headerImg.src = headerDataUrl
  101. await new Promise((resolve) => {
  102. headerImg.onload = resolve
  103. })
  104. // Load content image
  105. const contentImg = new Image()
  106. contentImg.src = contentDataUrl
  107. await new Promise((resolve) => {
  108. contentImg.onload = resolve
  109. })
  110. // A4 size: 210mm x 297mm
  111. const pdfWidth = 210
  112. const pdfHeight = 297
  113. const margin = 10
  114. // Calculate scaling based on content width
  115. const ratio = (pdfWidth - margin * 2) / contentImg.width
  116. const headerHeight = headerImg.height * ratio
  117. const contentHeight = contentImg.height * ratio
  118. // Create PDF
  119. const pdf = new jsPDF({
  120. orientation: 'portrait',
  121. unit: 'mm',
  122. format: 'a4',
  123. })
  124. const pageWidth = pdf.internal.pageSize.getWidth()
  125. const pageHeight = pdf.internal.pageSize.getHeight()
  126. const availableHeight = pageHeight - margin * 2
  127. let currentY = margin
  128. // Add header on first page
  129. pdf.addImage(
  130. headerDataUrl,
  131. 'PNG',
  132. margin,
  133. currentY,
  134. pdfWidth - margin * 2,
  135. headerHeight,
  136. )
  137. currentY += headerHeight
  138. // Check if content needs pagination
  139. const remainingHeight = availableHeight - headerHeight
  140. if (contentHeight <= remainingHeight) {
  141. // Content fits on first page
  142. pdf.addImage(
  143. contentDataUrl,
  144. 'PNG',
  145. margin,
  146. currentY,
  147. pdfWidth - margin * 2,
  148. contentHeight,
  149. )
  150. }
  151. else {
  152. // Content needs multiple pages
  153. const totalPages = Math.ceil((headerHeight + contentHeight) / availableHeight)
  154. // First page: remaining space after header
  155. const firstPageContentHeight = remainingHeight
  156. const sourceHeightRatio = firstPageContentHeight / contentHeight
  157. const sourcePixelHeight = contentImg.height * sourceHeightRatio
  158. let canvas = document.createElement('canvas')
  159. canvas.width = contentImg.width
  160. canvas.height = sourcePixelHeight
  161. let ctx = canvas.getContext('2d')
  162. if (ctx) {
  163. ctx.fillStyle = '#ffffff'
  164. ctx.fillRect(0, 0, canvas.width, canvas.height)
  165. ctx.drawImage(
  166. contentImg,
  167. 0, 0, contentImg.width, sourcePixelHeight,
  168. 0, 0, canvas.width, canvas.height,
  169. )
  170. pdf.addImage(canvas.toDataURL('image/png', 0.95), 'PNG', margin, currentY, pdfWidth - margin * 2, firstPageContentHeight)
  171. }
  172. // Subsequent pages
  173. let remainingContentHeight = contentHeight - firstPageContentHeight
  174. let sourceY = sourcePixelHeight
  175. let pageNum = 2
  176. while (remainingContentHeight > 0) {
  177. pdf.addPage()
  178. const pageContentHeight = Math.min(availableHeight, remainingContentHeight)
  179. const pageSourceHeightRatio = pageContentHeight / contentHeight
  180. const pageSourcePixelHeight = contentImg.height * pageSourceHeightRatio
  181. canvas = document.createElement('canvas')
  182. canvas.width = contentImg.width
  183. canvas.height = pageSourcePixelHeight
  184. ctx = canvas.getContext('2d')
  185. if (ctx) {
  186. ctx.fillStyle = '#ffffff'
  187. ctx.fillRect(0, 0, canvas.width, canvas.height)
  188. ctx.drawImage(
  189. contentImg,
  190. 0, sourceY, contentImg.width, pageSourcePixelHeight,
  191. 0, 0, canvas.width, canvas.height,
  192. )
  193. pdf.addImage(canvas.toDataURL('image/png', 0.95), 'PNG', margin, margin, pdfWidth - margin * 2, pageContentHeight)
  194. }
  195. sourceY += pageSourcePixelHeight
  196. remainingContentHeight -= pageContentHeight
  197. pageNum++
  198. }
  199. // Add page numbers
  200. const totalPagesCount = pdf.getNumberOfPages()
  201. for (let i = 1; i <= totalPagesCount; i++) {
  202. pdf.setPage(i)
  203. pdf.setFontSize(9)
  204. pdf.setTextColor(156, 163, 175)
  205. pdf.text(
  206. `${i} / ${totalPagesCount}`,
  207. pageWidth / 2,
  208. pageHeight - 5,
  209. { align: 'center' },
  210. )
  211. }
  212. }
  213. // Save PDF
  214. pdf.save(filename)
  215. }
  216. catch (error) {
  217. console.error('Failed to export PDF:', error)
  218. throw error
  219. }
  220. }