From f32bbe59b3b43da69441d1fc4fe61b5a2ab7ee79 Mon Sep 17 00:00:00 2001 From: hyper Date: Fri, 27 Feb 2026 20:27:26 +0800 Subject: [PATCH] feat: jsPDF --- package-lock.json | 214 +++++++++++++++++++++- package.json | 2 + src/components/md-deck/DeckHeader.tsx | 6 +- src/components/md-deck/PrintPreview.tsx | 140 ++++++++++++-- src/components/md-deck/hooks/deckStore.ts | 22 +-- src/components/md-deck/index.tsx | 8 +- 6 files changed, 355 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a3405a..022901d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,15 @@ "chokidar": "^5.0.0", "commander": "^12.1.0", "csv-parse": "^5.5.6", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.0", "marked": "^14.1.0", "marked-directive": "^1.0.7", "solid-element": "^1.9.1", "solid-js": "^1.9.3" }, "bin": { - "ttrpg": "dist/cli.js" + "ttrpg": "dist/cli/index.js" }, "devDependencies": { "@rsbuild/core": "^1.1.8", @@ -483,6 +485,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2218,6 +2229,26 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "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==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2309,6 +2340,15 @@ } } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -2377,6 +2417,26 @@ ], "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==", + "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/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -2418,7 +2478,7 @@ "version": "3.47.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "funding": { @@ -2433,6 +2493,15 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2506,6 +2575,16 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", @@ -2593,6 +2672,17 @@ "node": ">=6" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2612,6 +2702,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2652,6 +2748,25 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2704,6 +2819,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", + "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -3055,6 +3187,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3068,6 +3206,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "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==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3132,6 +3277,16 @@ "node": ">=4" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -3152,6 +3307,23 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "optional": true + }, + "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==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -3277,6 +3449,26 @@ "node": ">=0.10.0" } }, + "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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -3298,6 +3490,15 @@ "url": "https://opencollective.com/webpack" } }, + "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==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3437,6 +3638,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index c6bede1..9ffab60 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "chokidar": "^5.0.0", "commander": "^12.1.0", "csv-parse": "^5.5.6", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.0", "marked": "^14.1.0", "marked-directive": "^1.0.7", "solid-element": "^1.9.1", diff --git a/src/components/md-deck/DeckHeader.tsx b/src/components/md-deck/DeckHeader.tsx index b0b2a64..dd3d96f 100644 --- a/src/components/md-deck/DeckHeader.tsx +++ b/src/components/md-deck/DeckHeader.tsx @@ -25,12 +25,12 @@ export function DeckHeader(props: DeckHeaderProps) { {store.state.isEditing ? '✓ 编辑中' : '✏️ 编辑'} - {/* 打印按钮 */} + {/* 导出 PDF 按钮 */} {/* Tab 选择器 */} diff --git a/src/components/md-deck/PrintPreview.tsx b/src/components/md-deck/PrintPreview.tsx index 2bace3c..8990411 100644 --- a/src/components/md-deck/PrintPreview.tsx +++ b/src/components/md-deck/PrintPreview.tsx @@ -2,11 +2,12 @@ import { For, createMemo } from 'solid-js'; import { marked } from '../../markdown'; import { getLayerStyle } from './hooks/dimensions'; import type { DeckStore } from './hooks/deckStore'; +import jsPDF from 'jspdf'; export interface PrintPreviewProps { store: DeckStore; onClose: () => void; - onPrint: () => void; + onExport: () => void; } /** @@ -179,6 +180,120 @@ export function PrintPreview(props: PrintPreviewProps) { const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible)); + // 导出 PDF + const handleExportPDF = async () => { + const pagesData = pages(); + const a4Size = getA4Size(); + + // 创建 jsPDF 实例 + const pdf = new jsPDF({ + orientation: orientation() === 'landscape' ? 'landscape' : 'portrait', + unit: 'mm', + format: 'a4' + }); + + const cardWidth = store.state.dimensions?.cardWidth || 56; + const cardHeight = store.state.dimensions?.cardHeight || 88; + const gridOriginX = store.state.dimensions?.gridOriginX || 0; + const gridOriginY = store.state.dimensions?.gridOriginY || 0; + const gridAreaWidth = store.state.dimensions?.gridAreaWidth || cardWidth; + const gridAreaHeight = store.state.dimensions?.gridAreaHeight || cardHeight; + const fontSize = store.state.dimensions?.fontSize || 3; + + // 为每页生成内容 + for (let i = 0; i < pagesData.length; i++) { + if (i > 0) { + pdf.addPage(); + } + + const page = pagesData()[i]; + const cropData = cropMarks()[i]; + + // 绘制外围边框 + const frameMargin = cropData.frameBoundsWithMargin; + pdf.setDrawColor(0); + pdf.setLineWidth(0.2); + pdf.rect(frameMargin.x, frameMargin.y, frameMargin.width, frameMargin.height); + + // 绘制水平裁切线 + for (const line of cropData.horizontalLines) { + pdf.setDrawColor(136); + pdf.setLineWidth(0.1); + // 左侧裁切线 + pdf.line(line.xStart, line.y, page.frameBounds.minX, line.y); + // 右侧裁切线 + pdf.line(page.frameBounds.maxX, line.y, line.xEnd, line.y); + } + + // 绘制垂直裁切线 + for (const line of cropData.verticalLines) { + pdf.setDrawColor(136); + pdf.setLineWidth(0.1); + // 上方裁切线 + pdf.line(line.x, line.yStart, line.x, page.frameBounds.minY); + // 下方裁切线 + pdf.line(line.x, page.frameBounds.maxY, line.x, line.yEnd); + } + + // 渲染卡牌内容 + for (const card of page.cards) { + // 创建临时容器渲染卡牌内容 + const container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.left = '-9999px'; + container.style.top = '-9999px'; + container.style.width = `${cardWidth}mm`; + container.style.height = `${cardHeight}mm`; + container.style.background = 'white'; + + // 网格区域容器 + const gridContainer = document.createElement('div'); + gridContainer.style.position = 'absolute'; + gridContainer.style.left = `${gridOriginX}mm`; + gridContainer.style.top = `${gridOriginY}mm`; + gridContainer.style.width = `${gridAreaWidth}mm`; + gridContainer.style.height = `${gridAreaHeight}mm`; + + // 渲染每个 layer + for (const layer of visibleLayers()) { + const layerEl = document.createElement('div'); + layerEl.className = 'absolute flex items-center justify-center text-center prose prose-sm'; + Object.assign(layerEl.style, getLayerStyle(layer, store.state.dimensions!)); + layerEl.style.fontSize = `${fontSize}mm`; + layerEl.innerHTML = renderLayerContent(layer, card.data); + gridContainer.appendChild(layerEl); + } + + container.appendChild(gridContainer); + document.body.appendChild(container); + + // 使用 html2canvas 渲染 + try { + const html2canvas = (await import('html2canvas')).default; + const canvas = await html2canvas(container, { + scale: 2, + backgroundColor: null, + logging: false, + useCORS: true + }); + + const imgData = canvas.toDataURL('image/png'); + pdf.addImage(imgData, 'PNG', card.x, card.y, cardWidth, cardHeight); + } catch (e) { + console.error('渲染卡牌内容失败:', e); + } + + document.body.removeChild(container); + } + } + + // 保存 PDF 文件 + pdf.save('deck.pdf'); + + // 关闭预览 + props.onClose(); + }; + // 渲染单个卡片的 SVG 内容(使用 foreignObject) const renderCardInSvg = (card: { data: typeof store.state.cards[0]; x: number; y: number }, pageIndex: number) => { const cardWidth = store.state.dimensions?.cardWidth || 56; @@ -230,17 +345,8 @@ export function PrintPreview(props: PrintPreviewProps) { }; return ( -
- {/* 打印样式:根据方向设置 @page 规则 */} - -
+
+
{/* 打印预览控制栏 */}
@@ -303,11 +409,11 @@ export function PrintPreview(props: PrintPreviewProps) {