refactor: pdf clean up
This commit is contained in:
@@ -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<HTMLCanvasElement> {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -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<typeof createMemo<PageData[]>>;
|
||||
cropMarks: ReturnType<typeof createMemo<CropMarkData[]>>;
|
||||
}
|
||||
|
||||
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<PageData[]>(() => {
|
||||
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<CropMarkData[]>(() => {
|
||||
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<number>();
|
||||
const yPositions = new Set<number>();
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user