diff --git a/src/components/md-deck/CardLayer.tsx b/src/components/md-deck/CardLayer.tsx
new file mode 100644
index 0000000..23acba8
--- /dev/null
+++ b/src/components/md-deck/CardLayer.tsx
@@ -0,0 +1,43 @@
+import { For } from 'solid-js';
+import { marked } from '../../markdown';
+import { getLayerStyle } from './hooks/dimensions';
+import type { LayerConfig, Dimensions, CardData } from './types';
+
+export interface CardLayerProps {
+ layers: LayerConfig[];
+ dimensions: Dimensions;
+ cardData: CardData;
+}
+
+/**
+ * 处理 body 内容中的 {{prop}} 语法并解析 markdown
+ */
+function processBody(body: string, currentRow: CardData): string {
+ const processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
+ return marked.parse(processedBody) as string;
+}
+
+/**
+ * 渲染 layer 内容
+ */
+function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
+ const content = cardData[layer.prop] || '';
+ return processBody(content, cardData);
+}
+
+export function CardLayer(props: CardLayerProps) {
+ return (
+
+ {(layer) => (
+
+ )}
+
+ );
+}
diff --git a/src/components/md-deck/CardPreview.tsx b/src/components/md-deck/CardPreview.tsx
index 1cfca1e..591fec3 100644
--- a/src/components/md-deck/CardPreview.tsx
+++ b/src/components/md-deck/CardPreview.tsx
@@ -1,40 +1,19 @@
import { Show, For, createMemo } from 'solid-js';
-import { marked } from '../../markdown';
-import { getLayerStyle } from './hooks/dimensions';
import { useCardSelection } from './hooks/useCardSelection';
import { getSelectionBoxStyle } from './hooks/useCardSelection';
+import { CardLayer } from './CardLayer';
import type { DeckStore } from './hooks/deckStore';
-import type { CardData } from './types';
export interface CardPreviewProps {
store: DeckStore;
}
-/**
- * 处理 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);
-}
-
/**
* 卡牌预览组件
*/
export function CardPreview(props: CardPreviewProps) {
const { store } = props;
- // 使用 createMemo 优化计算
const currentCard = createMemo(() => store.state.cards[store.state.activeTab]);
const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible));
const selectionStyle = createMemo(() =>
@@ -60,7 +39,6 @@ export function CardPreview(props: CardPreviewProps) {
onMouseUp={selection.onMouseUp}
onMouseLeave={selection.onMouseLeave}
>
- {/* 框选遮罩 */}
- {/* 网格区域容器 */}
- {/* 编辑模式下的网格线 */}
@@ -100,23 +76,11 @@ export function CardPreview(props: CardPreviewProps) {
- {/* 渲染每个 layer */}
-
- {(layer) => {
- return (
-
- );
- }}
-
+
diff --git a/src/components/md-deck/PrintPreview.tsx b/src/components/md-deck/PrintPreview.tsx
index ea6ffd1..82ddda5 100644
--- a/src/components/md-deck/PrintPreview.tsx
+++ b/src/components/md-deck/PrintPreview.tsx
@@ -1,9 +1,10 @@
-import { For, createMemo } from 'solid-js';
-import { marked } from '../../markdown';
-import { getLayerStyle } from './hooks/dimensions';
+import { For } from 'solid-js';
import type { DeckStore } from './hooks/deckStore';
-import type { CardData } from './types';
-import jsPDF from 'jspdf';
+import { usePageLayout } from './hooks/usePageLayout';
+import { usePDFExport, type ExportOptions } from './hooks/usePDFExport';
+import { PrintPreviewHeader } from './PrintPreviewHeader';
+import { PrintPreviewFooter } from './PrintPreviewFooter';
+import { CardLayer } from './CardLayer';
export interface PrintPreviewProps {
store: DeckStore;
@@ -11,483 +12,44 @@ export interface PrintPreviewProps {
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;
+ const { getA4Size, pages, cropMarks } = usePageLayout(store);
+ const { exportToPDF } = usePDFExport(store, props.onClose);
- // 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 visibleLayers = () => store.state.layerConfigs.filter((l) => l.visible);
- // 获取打印设置
- 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();
- const totalPages = pagesData.length;
-
- // 重置状态
- store.actions.setExportProgress(0);
- store.actions.setExportError(null);
-
- try {
- // 创建 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 < totalPages; 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);
- }
-
- // 渲染卡牌内容
- const totalCards = page.cards.length;
- for (let j = 0; j < totalCards; j++) {
- const card = page.cards[j];
-
- // 创建临时容器渲染卡牌内容
- 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);
-
- // 更新进度(按卡牌数量计算)
- const currentCardIndex = i * totalCards + j + 1;
- const totalCardCount = totalPages * totalCards;
- const progress = Math.round((currentCardIndex / totalCardCount) * 100);
- store.actions.setExportProgress(progress);
- }
- }
-
- // 保存 PDF 文件
- pdf.save('deck.pdf');
-
- // 完成,关闭预览
- props.onClose();
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : '导出失败,未知错误';
- store.actions.setExportError(errorMsg);
- console.error('PDF 导出失败:', err);
- }
- };
-
- // 渲染单个卡片的 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) => (
-
- )}
-
-
-
-
-
- );
+ const handleExport = async () => {
+ const options: ExportOptions = {
+ orientation: store.state.printOrientation,
+ cardWidth: store.state.dimensions?.cardWidth || 56,
+ cardHeight: store.state.dimensions?.cardHeight || 88,
+ gridOriginX: store.state.dimensions?.gridOriginX || 0,
+ gridOriginY: store.state.dimensions?.gridOriginY || 0,
+ gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56,
+ gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88,
+ fontSize: store.state.dimensions?.fontSize || 3,
+ visibleLayers: visibleLayers(),
+ dimensions: store.state.dimensions!
+ };
+ await exportToPDF(pages(), cropMarks(), options);
};
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
-
-
-
-
-
-
-
-
+
- {/* 进度条和错误信息 */}
-
0 || store.state.exportError}>
-
-
-
-
导出进度:
-
-
{store.state.exportProgress}%
-
-
-
-
-
- ❌
- {store.state.exportError}
-
-
-
-
-
-
+
- {/* A4 纸张预览:每页都是一个完整的 SVG */}
{(page) => (
@@ -501,7 +63,6 @@ export function PrintPreview(props: PrintPreviewProps) {
data-page={page.pageIndex + 1}
xmlns="http://www.w3.org/2000/svg"
>
- {/* 外围边框:黑色 0.2mm */}
- {/* 水平裁切线 */}
{(line) => (
<>
- {/* 左侧裁切线(外围框外部) */}
- {/* 右侧裁切线(外围框外部) */}
)}
- {/* 垂直裁切线 */}
+
{(line) => (
<>
- {/* 上方裁切线(外围框外部) */}
- {/* 下方裁切线(外围框外部) */}
- {/* 渲染该页的所有卡牌 */}
- {(card) => renderCardInSvg(card, page.pageIndex)}
+ {(card) => (
+
+
+
+
+
+ )}
)}
diff --git a/src/components/md-deck/PrintPreviewFooter.tsx b/src/components/md-deck/PrintPreviewFooter.tsx
new file mode 100644
index 0000000..b3676f4
--- /dev/null
+++ b/src/components/md-deck/PrintPreviewFooter.tsx
@@ -0,0 +1,43 @@
+import { Show } from 'solid-js';
+import type { DeckStore } from './hooks/deckStore';
+
+export interface PrintPreviewFooterProps {
+ store: DeckStore;
+}
+
+export function PrintPreviewFooter(props: PrintPreviewFooterProps) {
+ const { store } = props;
+
+ return (
+ 0 || store.state.exportError}>
+
+
+
+
导出进度:
+
+
{store.state.exportProgress}%
+
+
+
+
+
+ ❌
+ {store.state.exportError}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/md-deck/PrintPreviewHeader.tsx b/src/components/md-deck/PrintPreviewHeader.tsx
new file mode 100644
index 0000000..7aa5f40
--- /dev/null
+++ b/src/components/md-deck/PrintPreviewHeader.tsx
@@ -0,0 +1,91 @@
+import type { DeckStore } from './hooks/deckStore';
+
+export interface PrintPreviewHeaderProps {
+ store: DeckStore;
+ pageCount: number;
+ onExport: () => void;
+ onClose: () => void;
+}
+
+export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
+ const { store } = props;
+ const orientation = () => store.state.printOrientation;
+ const oddPageOffsetX = () => store.state.printOddPageOffsetX;
+ const oddPageOffsetY = () => store.state.printOddPageOffsetY;
+
+ return (
+
+
+
打印预览
+
共 {props.pageCount} 页,{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
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/md-deck/hooks/usePDFExport.ts b/src/components/md-deck/hooks/usePDFExport.ts
new file mode 100644
index 0000000..fb06863
--- /dev/null
+++ b/src/components/md-deck/hooks/usePDFExport.ts
@@ -0,0 +1,183 @@
+import { marked } from '../../../markdown';
+import { getLayerStyle } from './dimensions';
+import type { DeckStore } from './deckStore';
+import type { CardData, LayerConfig, Dimensions } from '../types';
+import jsPDF from 'jspdf';
+
+/**
+ * 处理 body 内容中的 {{prop}} 语法并解析 markdown
+ */
+function processBody(body: string, currentRow: CardData): string {
+ const processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
+ return marked.parse(processedBody) as string;
+}
+
+/**
+ * 渲染 layer 内容
+ */
+function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
+ const content = cardData[layer.prop] || '';
+ return processBody(content, cardData);
+}
+
+export interface PageCard {
+ data: CardData;
+ x: number;
+ y: number;
+}
+
+export interface PageData {
+ pageIndex: number;
+ cards: PageCard[];
+ bounds: { minX: number; minY: number; maxX: number; maxY: number };
+ frameBounds: { minX: number; minY: number; maxX: number; maxY: number };
+}
+
+export interface CropMarkData {
+ horizontalLines: { y: number; xStart: number; xEnd: number }[];
+ verticalLines: { x: number; yStart: number; yEnd: number }[];
+ frameBounds: { minX: number; minY: number; maxX: number; maxY: number };
+ frameBoundsWithMargin: { x: number; y: number; width: number; height: number };
+}
+
+export interface ExportOptions {
+ orientation: 'portrait' | 'landscape';
+ cardWidth: number;
+ cardHeight: number;
+ gridOriginX: number;
+ gridOriginY: number;
+ gridAreaWidth: number;
+ gridAreaHeight: number;
+ fontSize: number;
+ visibleLayers: LayerConfig[];
+ dimensions: Dimensions;
+}
+
+/**
+ * 渲染卡牌到 canvas
+ */
+async function renderCardToCanvas(card: PageCard, options: ExportOptions): Promise {
+ const { cardWidth, cardHeight, gridOriginX, gridOriginY, gridAreaWidth, gridAreaHeight, fontSize, visibleLayers, dimensions } = options;
+
+ 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`;
+
+ 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, dimensions));
+ layerEl.style.fontSize = `${fontSize}mm`;
+ layerEl.innerHTML = renderLayerContent(layer, card.data);
+ gridContainer.appendChild(layerEl);
+ }
+
+ container.appendChild(gridContainer);
+ document.body.appendChild(container);
+
+ try {
+ const html2canvas = (await import('html2canvas')).default;
+ return await html2canvas(container, {
+ scale: 2,
+ backgroundColor: null,
+ logging: false,
+ useCORS: true
+ });
+ } finally {
+ document.body.removeChild(container);
+ }
+}
+
+/**
+ * 绘制页面边框和裁切线
+ */
+function drawPageMarks(pdf: jsPDF, cropData: CropMarkData, pageFrameBounds: { minX: number; maxX: number; minY: number; maxY: number }) {
+ const frameMargin = cropData.frameBoundsWithMargin;
+
+ // 外围边框
+ pdf.setDrawColor(0);
+ pdf.setLineWidth(0.2);
+ pdf.rect(frameMargin.x, frameMargin.y, frameMargin.width, frameMargin.height);
+
+ // 水平裁切线
+ pdf.setDrawColor(136);
+ pdf.setLineWidth(0.1);
+ for (const line of cropData.horizontalLines) {
+ pdf.line(line.xStart, line.y, pageFrameBounds.minX, line.y);
+ pdf.line(pageFrameBounds.maxX, line.y, line.xEnd, line.y);
+ }
+
+ // 垂直裁切线
+ for (const line of cropData.verticalLines) {
+ pdf.line(line.x, line.yStart, line.x, pageFrameBounds.minY);
+ pdf.line(line.x, pageFrameBounds.maxY, line.x, line.yEnd);
+ }
+}
+
+export interface UsePDFExportReturn {
+ exportToPDF: (pages: PageData[], cropMarks: CropMarkData[], options: ExportOptions) => Promise;
+}
+
+/**
+ * PDF 导出 hook
+ */
+export function usePDFExport(store: DeckStore, onClose: () => void): UsePDFExportReturn {
+ const exportToPDF = async (pages: PageData[], cropMarks: CropMarkData[], options: ExportOptions) => {
+ const totalPages = pages.length;
+
+ store.actions.setExportProgress(0);
+ store.actions.setExportError(null);
+
+ try {
+ const pdf = new jsPDF({
+ orientation: options.orientation,
+ unit: 'mm',
+ format: 'a4'
+ });
+
+ for (let i = 0; i < totalPages; i++) {
+ if (i > 0) {
+ pdf.addPage();
+ }
+
+ const page = pages[i];
+ const cropData = cropMarks[i];
+
+ drawPageMarks(pdf, cropData, page.frameBounds);
+
+ const totalCards = page.cards.length;
+ for (let j = 0; j < totalCards; j++) {
+ const card = page.cards[j];
+ const canvas = await renderCardToCanvas(card, options);
+ const imgData = canvas.toDataURL('image/png');
+ pdf.addImage(imgData, 'PNG', card.x, card.y, options.cardWidth, options.cardHeight);
+
+ const currentCardIndex = i * totalCards + j + 1;
+ const totalCardCount = totalPages * totalCards;
+ const progress = Math.round((currentCardIndex / totalCardCount) * 100);
+ store.actions.setExportProgress(progress);
+ }
+ }
+
+ pdf.save('deck.pdf');
+ onClose();
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : '导出失败,未知错误';
+ store.actions.setExportError(errorMsg);
+ console.error('PDF 导出失败:', err);
+ }
+ };
+
+ return { exportToPDF };
+}
diff --git a/src/components/md-deck/hooks/usePageLayout.ts b/src/components/md-deck/hooks/usePageLayout.ts
new file mode 100644
index 0000000..27ffd37
--- /dev/null
+++ b/src/components/md-deck/hooks/usePageLayout.ts
@@ -0,0 +1,153 @@
+import { createMemo } from 'solid-js';
+import type { DeckStore } from './deckStore';
+import type { PageData, CropMarkData } from './usePDFExport';
+
+export interface A4Size {
+ width: number;
+ height: number;
+}
+
+export interface UsePageLayoutReturn {
+ getA4Size: () => A4Size;
+ pages: ReturnType>;
+ cropMarks: ReturnType>;
+}
+
+const A4_WIDTH_PORTRAIT = 210;
+const A4_HEIGHT_PORTRAIT = 297;
+const A4_WIDTH_LANDSCAPE = 297;
+const A4_HEIGHT_LANDSCAPE = 210;
+const PRINT_MARGIN = 5;
+
+/**
+ * 页面布局计算 hook
+ */
+export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
+ const orientation = () => store.state.printOrientation;
+ const oddPageOffsetX = () => store.state.printOddPageOffsetX;
+ const oddPageOffsetY = () => store.state.printOddPageOffsetY;
+
+ const getA4Size = () => {
+ if (orientation() === 'landscape') {
+ return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE };
+ }
+ return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT };
+ };
+
+ 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;
+ const baseOffsetX = (a4Width - maxGridWidth) / 2;
+ const baseOffsetY = (a4Height - maxGridHeight) / 2;
+
+ const result: PageData[] = [];
+ let currentPage: PageData = {
+ pageIndex: 0,
+ cards: [],
+ bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+ frameBounds: { 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 },
+ frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
+ };
+ }
+
+ 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 });
+ 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;
+
+ const horizontalLines = sortedY.map(y => ({
+ y,
+ xStart: frameBounds.minX - OVERLAP,
+ xEnd: frameBounds.maxX + OVERLAP
+ }));
+
+ const verticalLines = sortedX.map(x => ({
+ x,
+ yStart: frameBounds.minY - OVERLAP,
+ yEnd: frameBounds.maxY + OVERLAP
+ }));
+
+ 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 };
+ });
+ });
+
+ return { getA4Size, pages, cropMarks };
+}