import { For, createMemo } from 'solid-js'; import { marked } from '../../markdown'; import { getLayerStyle } from './hooks/dimensions'; import type { DeckStore } from './hooks/deckStore'; import type { CardData } from './types'; import jsPDF from 'jspdf'; export interface PrintPreviewProps { store: DeckStore; onClose: () => void; onExport: () => void; } /** * 处理 body 内容中的 {{prop}} 语法并解析 markdown */ function processBody(body: string, currentRow: CardData): string { // 替换 {{prop}} 为对应列的内容 const processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || ''); // 使用 marked 解析 markdown return marked.parse(processedBody) as string; } /** * 渲染 layer 内容 */ function renderLayerContent(layer: { prop: string }, cardData: CardData): string { const content = cardData[layer.prop] || ''; return processBody(content, cardData); } /** * 打印预览组件:在 A4 纸张上排列所有卡牌 */ export function PrintPreview(props: PrintPreviewProps) { const { store } = props; // A4 纸张尺寸(mm):210 x 297 const A4_WIDTH_PORTRAIT = 210; const A4_HEIGHT_PORTRAIT = 297; const A4_WIDTH_LANDSCAPE = 297; const A4_HEIGHT_LANDSCAPE = 210; const PRINT_MARGIN = 5; // 打印边距 // 获取打印设置 const orientation = () => store.state.printOrientation; const oddPageOffsetX = () => store.state.printOddPageOffsetX; const oddPageOffsetY = () => store.state.printOddPageOffsetY; // 根据方向获取 A4 尺寸 const getA4Size = () => { if (orientation() === 'landscape') { return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE }; } return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT }; }; // 计算每张卡牌在 A4 纸上的位置(居中布局) const pages = createMemo(() => { const cards = store.state.cards; const cardWidth = store.state.dimensions?.cardWidth || 56; const cardHeight = store.state.dimensions?.cardHeight || 88; const { width: a4Width, height: a4Height } = getA4Size(); // 每行可容纳的卡牌数量 const usableWidth = a4Width - PRINT_MARGIN * 2; const cardsPerRow = Math.floor(usableWidth / cardWidth); // 每页可容纳的行数 const usableHeight = a4Height - PRINT_MARGIN * 2; const rowsPerPage = Math.floor(usableHeight / cardHeight); // 每页的卡牌数量 const cardsPerPage = cardsPerRow * rowsPerPage; // 计算最大卡牌区域的尺寸(用于居中和外围框) const maxGridWidth = cardsPerRow * cardWidth; const maxGridHeight = rowsPerPage * cardHeight; // 居中偏移量(使卡牌区域在 A4 纸上居中) const baseOffsetX = (a4Width - maxGridWidth) / 2; const baseOffsetY = (a4Height - maxGridHeight) / 2; // 分页 const result: { pageIndex: number; cards: Array<{ data: typeof cards[0]; x: number; y: number }>; bounds: { minX: number; minY: number; maxX: number; maxY: number }; }[] = []; let currentPage: typeof result[0] = { pageIndex: 0, cards: [], bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } }; for (let i = 0; i < cards.length; i++) { const pageIndex = Math.floor(i / cardsPerPage); const indexInPage = i % cardsPerPage; const row = Math.floor(indexInPage / cardsPerRow); const col = indexInPage % cardsPerRow; if (pageIndex !== currentPage.pageIndex) { result.push(currentPage); currentPage = { pageIndex, cards: [], bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } }; } // 奇数页应用偏移(pageIndex 从 0 开始,所以偶数索引是奇数页) const isOddPage = pageIndex % 2 === 0; const pageOffsetX = isOddPage ? oddPageOffsetX() : 0; const pageOffsetY = isOddPage ? oddPageOffsetY() : 0; // 使用居中偏移量 + 奇数页偏移计算卡牌位置 const cardX = baseOffsetX + col * cardWidth + pageOffsetX; const cardY = baseOffsetY + row * cardHeight + pageOffsetY; currentPage.cards.push({ data: cards[i], x: cardX, y: cardY }); // 更新边界(含 1mm 边距) currentPage.bounds.minX = Math.min(currentPage.bounds.minX, cardX); currentPage.bounds.minY = Math.min(currentPage.bounds.minY, cardY); currentPage.bounds.maxX = Math.max(currentPage.bounds.maxX, cardX + cardWidth); currentPage.bounds.maxY = Math.max(currentPage.bounds.maxY, cardY + cardHeight); } if (currentPage.cards.length > 0) { result.push(currentPage); } // 为每页添加固定的外围框尺寸(基于最大网格) return result.map(page => ({ ...page, frameBounds: { minX: baseOffsetX + (page.pageIndex % 2 === 0 ? oddPageOffsetX() : 0), minY: baseOffsetY + (page.pageIndex % 2 === 0 ? oddPageOffsetY() : 0), maxX: baseOffsetX + maxGridWidth + (page.pageIndex % 2 === 0 ? oddPageOffsetX() : 0), maxY: baseOffsetY + maxGridHeight + (page.pageIndex % 2 === 0 ? oddPageOffsetY() : 0) } })); }); // 计算裁切线和外围框位置 const cropMarks = createMemo(() => { const pagesData = pages(); return pagesData.map(page => { const { frameBounds, cards } = page; const cardWidth = store.state.dimensions?.cardWidth || 56; const cardHeight = store.state.dimensions?.cardHeight || 88; // 收集所有唯一的裁切线位置 const xPositions = new Set(); const yPositions = new Set(); cards.forEach(card => { xPositions.add(card.x); xPositions.add(card.x + cardWidth); yPositions.add(card.y); yPositions.add(card.y + cardHeight); }); const sortedX = Array.from(xPositions).sort((a, b) => a - b); const sortedY = Array.from(yPositions).sort((a, b) => a - b); // 裁切线超出外围框的距离 const OVERLAP = 3; // 3mm // 生成水平裁切线(沿 Y 轴) const horizontalLines = sortedY.map(y => ({ y, xStart: frameBounds.minX - OVERLAP, xEnd: frameBounds.maxX + OVERLAP })); // 生成垂直裁切线(沿 X 轴) const verticalLines = sortedX.map(x => ({ x, yStart: frameBounds.minY - OVERLAP, yEnd: frameBounds.maxY + OVERLAP })); // 外围框边界(离卡牌区域边缘 1mm) const frameBoundsWithMargin = { x: frameBounds.minX - 1, y: frameBounds.minY - 1, width: frameBounds.maxX - frameBounds.minX + 2, height: frameBounds.maxY - frameBounds.minY + 2 }; return { horizontalLines, verticalLines, frameBounds, frameBoundsWithMargin }; }); }); 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; 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; return (
{/* 网格区域容器 */}
{/* 渲染每个 layer */} {(layer) => (
)}
); }; return (
{/* 打印预览控制栏 */}

打印预览

共 {pages().length} 页,{store.state.cards.length} 张卡牌

{/* 方向选择 */}
{/* 奇数页偏移 */}
X: store.actions.setPrintOddPageOffsetX(Number(e.target.value))} class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" step="0.1" /> mm
Y: store.actions.setPrintOddPageOffsetY(Number(e.target.value))} class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" step="0.1" /> mm
{/* A4 纸张预览:每页都是一个完整的 SVG */}
{(page) => ( {/* 外围边框:黑色 0.2mm */} {/* 水平裁切线 */} {(line) => ( <> {/* 左侧裁切线(外围框外部) */} {/* 右侧裁切线(外围框外部) */} )} {/* 垂直裁切线 */} {(line) => ( <> {/* 上方裁切线(外围框外部) */} {/* 下方裁切线(外围框外部) */} )} {/* 渲染该页的所有卡牌 */} {(card) => renderCardInSvg(card, page.pageIndex)} )}
); }