Rework generation page and settings

This commit is contained in:
Roman Krček
2025-07-29 15:56:42 +02:00
parent 39b15f1314
commit 1fde370890
5 changed files with 711 additions and 636 deletions

1
src/fontkit.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'fontkit';

View File

@@ -4,22 +4,59 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import * as fontkit from 'fontkit'; import * as fontkit from 'fontkit';
import { import {
PDF_DIMENSIONS,
TEXT_PDF_GRID,
PHOTO_PDF_GRID,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT,
BORDER_CONFIG, BORDER_CONFIG,
TEXT_CONFIG, TEXT_CONFIG,
PLACEHOLDER_CONFIG, PLACEHOLDER_CONFIG,
calculateGridLayout, calculateGrid,
getAbsolutePosition, getAbsolutePositionPt,
getAbsolutePhotoDimensions getAbsolutePhotoDimensionsPt,
MM_TO_PT
} from '$lib/pdfLayout'; } from '$lib/pdfLayout';
import {
PAGE_SETTINGS,
TEXT_CARD_DIMENSIONS,
PHOTO_CARD_DIMENSIONS,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT
} from '$lib/pdfSettings';
let isGenerating = $state(false); type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
let progress = $state({ stage: '', current: 0, total: 0 });
let generatedFiles = $state<{ name: string; url: string; size: number }[]>([]); type GeneratedFile = {
name: string;
displayName: string;
state: FileGenerationState;
url: string | null;
size: number | null;
error: string | null;
};
const initialFiles: GeneratedFile[] = [
{
name: 'people_data.pdf',
displayName: 'Text PDF',
state: 'idle',
url: null,
size: null,
error: null
},
{
name: 'people_photos.pdf',
displayName: 'Photos PDF',
state: 'idle',
url: null,
size: null,
error: null
}
];
let files = $state<GeneratedFile[]>(JSON.parse(JSON.stringify(initialFiles)));
onMount(() => {
// Start generation automatically when the component mounts
handleGenerate('people_data.pdf');
handleGenerate('people_photos.pdf');
});
// Load Roboto font // Load Roboto font
async function loadRobotoFont() { async function loadRobotoFont() {
@@ -29,14 +66,17 @@
throw new Error('Failed to load Roboto font'); throw new Error('Failed to load Roboto font');
} }
return await fontResponse.arrayBuffer(); return await fontResponse.arrayBuffer();
} catch (error) { } catch (error: any) {
console.warn('Could not load Roboto font, falling back to standard font:', error); console.warn('Could not load Roboto font, falling back to standard font:', error);
return null; return null;
} }
} }
// Crop image using canvas // Crop image using canvas
async function cropImage(imageBlob: Blob, crop: { x: number; y: number; width: number; height: number }): Promise<Uint8Array> { async function cropImage(
imageBlob: Blob,
crop: { x: number; y: number; width: number; height: number }
): Promise<Uint8Array> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -55,20 +95,33 @@
// Draw the cropped portion of the image // Draw the cropped portion of the image
ctx.drawImage( ctx.drawImage(
img, img,
crop.x, crop.y, crop.width, crop.height, // Source rectangle crop.x,
0, 0, crop.width, crop.height // Destination rectangle crop.y,
crop.width,
crop.height, // Source rectangle
0,
0,
crop.width,
crop.height // Destination rectangle
); );
// Convert canvas to blob then to array buffer // Convert canvas to blob then to array buffer
canvas.toBlob((blob) => { canvas.toBlob(
(blob) => {
if (blob) { if (blob) {
blob.arrayBuffer().then(buffer => { blob
.arrayBuffer()
.then((buffer) => {
resolve(new Uint8Array(buffer)); resolve(new Uint8Array(buffer));
}).catch(reject); })
.catch(reject);
} else { } else {
reject(new Error('Failed to create blob from canvas')); reject(new Error('Failed to create blob from canvas'));
} }
}, 'image/jpeg', 0.9); },
'image/jpeg',
0.9
);
}; };
img.onerror = () => reject(new Error('Failed to load image')); img.onerror = () => reject(new Error('Failed to load image'));
@@ -76,132 +129,120 @@
}); });
} }
// PDF generation function async function handleGenerate(fileName: string) {
async function generatePDFs() { const fileToUpdate = files.find((f) => f.name === fileName);
if (isGenerating) return; if (!fileToUpdate || fileToUpdate.state === 'generating') return;
isGenerating = true; fileToUpdate.state = 'generating';
generatedFiles = []; fileToUpdate.error = null;
try { try {
console.log("starting PDF generation..."); const pdfBytes =
console.log("filteredSheetData:", $filteredSheetData); fileName === 'people_data.pdf' ? await generateTextPDF() : await generatePhotoPDF();
console.log("valid rows:", $filteredSheetData.filter(row => row._isValid));
// Generate text PDF const blob = new Blob([pdfBytes], { type: 'application/pdf' });
progress = { stage: 'Generating text PDF...', current: 1, total: 3 };
const textPdfBytes = await generateTextPDF();
const textBlob = new Blob([textPdfBytes], { type: 'application/pdf' });
const textUrl = URL.createObjectURL(textBlob);
generatedFiles.push({
name: 'people_data.pdf',
url: textUrl,
size: textPdfBytes.length
});
console.log("Text PDF generated:", textUrl); // Revoke old URL if it exists
if (fileToUpdate.url) {
URL.revokeObjectURL(fileToUpdate.url);
}
console.log("starting photo PDF generation..."); fileToUpdate.url = URL.createObjectURL(blob);
fileToUpdate.size = pdfBytes.length;
// Generate photo PDF fileToUpdate.state = 'done';
progress = { stage: 'Generating photo PDF...', current: 2, total: 3 }; } catch (error: any) {
const photoPdfBytes = await generatePhotoPDF(); console.error(`PDF generation failed for ${fileName}:`, error);
const photoBlob = new Blob([photoPdfBytes], { type: 'application/pdf' }); fileToUpdate.state = 'error';
const photoUrl = URL.createObjectURL(photoBlob); fileToUpdate.error = error.message || 'An unknown error occurred';
generatedFiles.push({
name: 'people_photos.pdf',
url: photoUrl,
size: photoPdfBytes.length
});
progress = { stage: 'Complete!', current: 3, total: 3 };
} catch (error) {
console.error('PDF generation failed:', error);
console.error('Error stack:', error.stack);
console.error('Error details:', {
message: error.message,
name: error.name,
stack: error.stack
});
alert('Failed to generate PDFs: ' + error.message);
} finally {
isGenerating = false;
console.log("PDF generation completed.");
} }
} }
async function generateTextPDF() { async function generateTextPDF() {
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
// Register fontkit to enable custom font embedding
pdfDoc.registerFontkit(fontkit); pdfDoc.registerFontkit(fontkit);
// Load custom Roboto font or fallback to standard font
const robotoFontBytes = await loadRobotoFont(); const robotoFontBytes = await loadRobotoFont();
const font = robotoFontBytes const font = robotoFontBytes
? await pdfDoc.embedFont(robotoFontBytes) ? await pdfDoc.embedFont(robotoFontBytes)
: await pdfDoc.embedFont(StandardFonts.TimesRoman); : await pdfDoc.embedFont(StandardFonts.TimesRoman);
// Calculate grid layout using configuration const gridLayout = calculateGrid(
const gridLayout = calculateGridLayout(PDF_DIMENSIONS, TEXT_PDF_GRID); PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin,
TEXT_CARD_DIMENSIONS.width,
TEXT_CARD_DIMENSIONS.height
);
const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
height: PAGE_SETTINGS.pageHeight * MM_TO_PT
};
let page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]); let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
let currentRow = 0; let currentRow = 0;
let currentCol = 0; let currentCol = 0;
const validRows = $filteredSheetData.filter(row => row._isValid); const validRows = $filteredSheetData.filter((row) => row._isValid);
for (let i = 0; i < validRows.length; i++) { for (let i = 0; i < validRows.length; i++) {
const row = validRows[i]; const row = validRows[i];
console.log(`Processing row ${i}:`, row); // Calculate cell position in mm
const cellX_mm = PAGE_SETTINGS.margin + currentCol * gridLayout.cellWidth;
const cellY_mm = PAGE_SETTINGS.margin + currentRow * gridLayout.cellHeight;
// Calculate cell position // Get field values
const cellX = PDF_DIMENSIONS.margin + currentCol * gridLayout.cellWidth;
const cellY = PDF_DIMENSIONS.pageHeight - PDF_DIMENSIONS.margin - (currentRow + 1) * gridLayout.cellHeight;
// Get field values safely
const name = row.name || row.Name || ''; const name = row.name || row.Name || '';
const surname = row.surname || row.Surname || row.lastname || row.LastName || ''; const surname = row.surname || row.Surname || row.lastname || row.LastName || '';
const nationality = row.nationality || row.Nationality || row.country || row.Country || ''; const nationality = row.nationality || row.Nationality || row.country || row.Country || '';
const birthday = row.birthday || row.Birthday || row.birthdate || row.Birthdate || row.birth_date || ''; const birthday =
row.birthday || row.Birthday || row.birthdate || row.Birthdate || row.birth_date || '';
// Draw name using absolute positioning // Draw name
const namePos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, TEXT_FIELD_LAYOUT.name); const namePos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.name
);
page.drawText(`${name} ${surname}`, { page.drawText(`${name} ${surname}`, {
x: namePos.x, ...namePos,
y: namePos.y, font,
size: namePos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
}); });
// Draw nationality // Draw nationality
const nationalityPos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, TEXT_FIELD_LAYOUT.nationality); const natPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.nationality
);
page.drawText(`Nationality: ${nationality}`, { page.drawText(`Nationality: ${nationality}`, {
x: nationalityPos.x, ...natPos,
y: nationalityPos.y, font,
size: nationalityPos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
}); });
// Draw birthday // Draw birthday
const birthdayPos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, TEXT_FIELD_LAYOUT.birthday); const bdayPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.birthday
);
page.drawText(`Birthday: ${birthday}`, { page.drawText(`Birthday: ${birthday}`, {
x: birthdayPos.x, ...bdayPos,
y: birthdayPos.y, font,
size: birthdayPos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
}); });
// Draw cell border // Draw cell border in points
page.drawRectangle({ page.drawRectangle({
x: cellX, x: cellX_mm * MM_TO_PT,
y: cellY, y: pageDimsPt.height - (cellY_mm + gridLayout.cellHeight) * MM_TO_PT,
width: gridLayout.cellWidth, width: gridLayout.cellWidth * MM_TO_PT,
height: gridLayout.cellHeight, height: gridLayout.cellHeight * MM_TO_PT,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b), borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width borderWidth: BORDER_CONFIG.width
}); });
@@ -212,8 +253,7 @@
currentCol = 0; currentCol = 0;
currentRow++; currentRow++;
if (currentRow >= gridLayout.rows) { if (currentRow >= gridLayout.rows) {
// Add new page page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]);
currentRow = 0; currentRow = 0;
} }
} }
@@ -224,142 +264,106 @@
async function generatePhotoPDF() { async function generatePhotoPDF() {
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
// Register fontkit to enable custom font embedding
pdfDoc.registerFontkit(fontkit); pdfDoc.registerFontkit(fontkit);
// Load custom Roboto font or fallback to standard font
const robotoFontBytes = await loadRobotoFont(); const robotoFontBytes = await loadRobotoFont();
const font = robotoFontBytes const font = robotoFontBytes
? await pdfDoc.embedFont(robotoFontBytes) ? await pdfDoc.embedFont(robotoFontBytes)
: await pdfDoc.embedFont(StandardFonts.TimesRoman); : await pdfDoc.embedFont(StandardFonts.TimesRoman);
// Calculate grid layout using configuration const gridLayout = calculateGrid(
const gridLayout = calculateGridLayout(PDF_DIMENSIONS, PHOTO_PDF_GRID); PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin,
PHOTO_CARD_DIMENSIONS.width,
PHOTO_CARD_DIMENSIONS.height
);
const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
height: PAGE_SETTINGS.pageHeight * MM_TO_PT
};
let page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]); let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
let currentRow = 0; let currentRow = 0;
let currentCol = 0; let currentCol = 0;
const validRows = $filteredSheetData.filter(row => row._isValid); const validRows = $filteredSheetData.filter((row) => row._isValid);
for (let i = 0; i < validRows.length; i++) { for (let i = 0; i < validRows.length; i++) {
const row = validRows[i]; const row = validRows[i];
// Calculate cell position // Calculate cell position in mm
const cellX = PDF_DIMENSIONS.margin + currentCol * gridLayout.cellWidth; const cellX_mm = PAGE_SETTINGS.margin + currentCol * gridLayout.cellWidth;
const cellY = PDF_DIMENSIONS.pageHeight - PDF_DIMENSIONS.margin - (currentRow + 1) * gridLayout.cellHeight; const cellY_mm = PAGE_SETTINGS.margin + currentRow * gridLayout.cellHeight;
// Get photo dimensions using configuration // Get photo dimensions in points
const photoDims = getAbsolutePhotoDimensions( const photoDimsPt = getAbsolutePhotoDimensionsPt(
cellX, cellY, gridLayout.cellWidth, gridLayout.cellHeight, cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.photo PHOTO_FIELD_LAYOUT.photo
); );
// Try to get and embed the actual photo
// Use picture URL as the key to lookup data
const pictureUrl = row.pictureUrl || row.picture_url || row.Picture || row.PictureUrl; const pictureUrl = row.pictureUrl || row.picture_url || row.Picture || row.PictureUrl;
const pictureInfo = $pictures[pictureUrl]; const pictureInfo = $pictures[pictureUrl];
const cropData = $cropRects[pictureUrl]; const cropData = $cropRects[pictureUrl];
console.log(`Row ${i} (${row.name}):`, {
rowId: row.id,
pictureUrl: pictureUrl,
pictureInfo: pictureInfo ? 'Found' : 'Missing',
cropData: cropData ? cropData : 'Missing',
allPictureIds: Object.keys($pictures),
allCropIds: Object.keys($cropRects)
});
if (pictureInfo && cropData) { if (pictureInfo && cropData) {
try { try {
console.log(`Cropping and embedding photo for ${row.name} ${row.surname}`);
// Crop the image
const croppedImageBytes = await cropImage(pictureInfo.blob, cropData); const croppedImageBytes = await cropImage(pictureInfo.blob, cropData);
const embeddedImage = await pdfDoc.embedJpg(croppedImageBytes);
// Embed the cropped image in the PDF const imageAspectRatio = embeddedImage.width / embeddedImage.height;
const image = await pdfDoc.embedJpg(croppedImageBytes); const photoBoxAspectRatio = photoDimsPt.width / photoDimsPt.height;
// Calculate image dimensions to fit within the photo area while maintaining aspect ratio
const imageAspectRatio = image.width / image.height;
const photoAspectRatio = photoDims.width / photoDims.height;
let imageWidth, imageHeight; let imageWidth, imageHeight;
if (imageAspectRatio > photoAspectRatio) { if (imageAspectRatio > photoBoxAspectRatio) {
// Image is wider - fit to width imageWidth = photoDimsPt.width;
imageWidth = photoDims.width; imageHeight = photoDimsPt.width / imageAspectRatio;
imageHeight = photoDims.width / imageAspectRatio;
} else { } else {
// Image is taller - fit to height imageHeight = photoDimsPt.height;
imageHeight = photoDims.height; imageWidth = photoDimsPt.height * imageAspectRatio;
imageWidth = photoDims.height * imageAspectRatio;
} }
// Center the image within the photo area const imageX = photoDimsPt.x + (photoDimsPt.width - imageWidth) / 2;
const imageX = photoDims.x + (photoDims.width - imageWidth) / 2; const imageY = photoDimsPt.y + (photoDimsPt.height - imageHeight) / 2;
const imageY = photoDims.y + (photoDims.height - imageHeight) / 2;
// Draw the image page.drawImage(embeddedImage, {
page.drawImage(image, {
x: imageX, x: imageX,
y: imageY, y: imageY,
width: imageWidth, width: imageWidth,
height: imageHeight height: imageHeight
}); });
} catch (error: any) {
} catch (error) {
console.error(`Failed to embed photo for ${row.name}:`, error); console.error(`Failed to embed photo for ${row.name}:`, error);
// Draw placeholder on error
// Fall back to placeholder if photo embedding fails
page.drawRectangle({ page.drawRectangle({
x: photoDims.x, ...photoDimsPt,
y: photoDims.y,
width: photoDims.width,
height: photoDims.height,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b), borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width borderWidth: BORDER_CONFIG.width
}); });
page.drawText('Photo failed', {
x: cellX + gridLayout.cellWidth / 2 - 30,
y: cellY + gridLayout.cellHeight / 2,
size: PLACEHOLDER_CONFIG.size,
font: font,
color: rgb(PLACEHOLDER_CONFIG.color.r, PLACEHOLDER_CONFIG.color.g, PLACEHOLDER_CONFIG.color.b)
});
} }
} else { } else {
// No photo or crop data available - draw placeholder // Draw placeholder if no photo
page.drawRectangle({ page.drawRectangle({
x: photoDims.x, ...photoDimsPt,
y: photoDims.y,
width: photoDims.width,
height: photoDims.height,
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b), borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
borderWidth: BORDER_CONFIG.width borderWidth: BORDER_CONFIG.width
}); });
page.drawText(PLACEHOLDER_CONFIG.text, {
x: cellX + gridLayout.cellWidth / 2 - 40,
y: cellY + gridLayout.cellHeight / 2,
size: PLACEHOLDER_CONFIG.size,
font: font,
color: rgb(PLACEHOLDER_CONFIG.color.r, PLACEHOLDER_CONFIG.color.g, PLACEHOLDER_CONFIG.color.b)
});
} }
// Get field values safely // Draw name
const name = row.name || row.Name || ''; const name = row.name || row.Name || '';
const surname = row.surname || row.Surname || row.lastname || row.LastName || ''; const surname = row.surname || row.Surname || row.lastname || row.LastName || '';
const namePos = getAbsolutePositionPt(
// Draw name using absolute positioning cellX_mm,
const namePos = getAbsolutePosition(cellX, cellY, gridLayout.cellHeight, PHOTO_FIELD_LAYOUT.name); cellY_mm,
PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.name
);
page.drawText(`${name} ${surname}`, { page.drawText(`${name} ${surname}`, {
x: namePos.x, ...namePos,
y: namePos.y, font,
size: namePos.size,
font: font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b) color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
}); });
@@ -369,8 +373,7 @@
currentCol = 0; currentCol = 0;
currentRow++; currentRow++;
if (currentRow >= gridLayout.rows) { if (currentRow >= gridLayout.rows) {
// Add new page page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
page = pdfDoc.addPage([PDF_DIMENSIONS.pageWidth, PDF_DIMENSIONS.pageHeight]);
currentRow = 0; currentRow = 0;
} }
} }
@@ -379,7 +382,8 @@
return await pdfDoc.save(); return await pdfDoc.save();
} }
function downloadFile(file: { name: string; url: string }) { function downloadFile(file: GeneratedFile) {
if (!file.url) return;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = file.url; link.href = file.url;
link.download = file.name; link.download = file.name;
@@ -388,8 +392,18 @@
document.body.removeChild(link); document.body.removeChild(link);
} }
function formatFileSize(bytes: number): string { function resetAndStartOver() {
if (bytes === 0) return '0 Bytes'; files.forEach((file) => {
if (file.url) {
URL.revokeObjectURL(file.url);
}
});
files = JSON.parse(JSON.stringify(initialFiles));
currentStep.set(0);
}
function formatFileSize(bytes: number | null): string {
if (bytes === null || bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
@@ -401,7 +415,7 @@
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2"> <h2 class="text-xl font-semibold text-gray-900 mb-2">
Generate PDFs Generating PDFs
</h2> </h2>
<p class="text-sm text-gray-700 mb-4"> <p class="text-sm text-gray-700 mb-4">
@@ -410,96 +424,120 @@
</div> </div>
<!-- Summary --> <!-- Summary -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6"> <div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3> <h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-gray-900"> <div class="text-2xl font-bold text-gray-900">
{$filteredSheetData.filter(row => row._isValid).length} {$filteredSheetData.filter((row) => row._isValid).length}
</div> </div>
<div class="text-gray-600">Records to Process</div> <div class="text-gray-600">Records to Process</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-blue-600">2</div> <div class="text-2xl font-bold text-blue-600">{files.length}</div>
<div class="text-gray-600">PDFs to Generate</div> <div class="text-gray-600">PDFs to Generate</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-green-600"> <div class="text-2xl font-bold text-green-600">
{generatedFiles.length} {files.filter((f) => f.state === 'done').length}
</div> </div>
<div class="text-gray-600">Files Ready</div> <div class="text-gray-600">Files Ready</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Generation Status -->
{#if isGenerating}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
<span class="text-sm text-blue-800">{progress.stage}</span>
</div>
<span class="text-sm text-blue-600">
{progress.current} / {progress.total}
</span>
</div>
<div class="w-full bg-blue-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {(progress.current / progress.total) * 100}%"
></div>
</div>
</div>
{/if}
<!-- Generate Button -->
{#if !isGenerating && generatedFiles.length === 0}
<div class="text-center mb-6">
<button
onclick={generatePDFs}
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Generate PDFs
</button>
</div>
{/if}
<!-- Generated Files --> <!-- Generated Files -->
{#if generatedFiles.length > 0}
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6"> <div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="p-4 border-b border-gray-200"> <div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Generated Files</h3> <h3 class="text-lg font-medium text-gray-900">Available Downloads</h3>
</div> </div>
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{#each generatedFiles as file} {#each files as file (file.name)}
<div class="p-4 flex items-center justify-between"> <div class="p-4 flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-8 h-8 text-red-600 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {#if file.displayName === 'Text PDF'}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/> <svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="12" x2="15" y2="12"></line>
</svg> </svg>
{:else if file.displayName === 'Photos PDF'}
<svg
class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<circle cx="12" cy="13" r="2"></circle>
<path d="M15 17.5c-1.5-1-4.5-1-6 0"></path>
</svg>
{/if}
<div> <div>
<h4 class="text-sm font-medium text-gray-900">{file.name}</h4> <h4 class="text-sm font-medium text-gray-900">{file.displayName}</h4>
{#if file.state === 'done' && file.size}
<p class="text-xs text-gray-500">{formatFileSize(file.size)}</p> <p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
{:else if file.state === 'error'}
<p class="text-xs text-red-500">Error: {file.error}</p>
{/if}
</div> </div>
</div> </div>
{#if file.state === 'idle'}
<button
onclick={() => handleGenerate(file.name)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Generate
</button>
{:else if file.state === 'generating'}
<button
disabled
class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
>
<div class="flex items-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
></div>
Generating...
</div>
</button>
{:else if file.state === 'done'}
<button <button
onclick={() => downloadFile(file)} onclick={() => downloadFile(file)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700" class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
> >
Download Download
</button> </button>
{:else if file.state === 'error'}
<button
onclick={() => handleGenerate(file.name)}
class="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700"
>
Retry
</button>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
{/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <div class="flex justify-between">
@@ -510,9 +548,9 @@
← Back to Gallery ← Back to Gallery
</button> </button>
{#if generatedFiles.length > 0} {#if files.some((f) => f.state === 'done' || f.state === 'error')}
<button <button
onclick={() => currentStep.set(0)} onclick={resetAndStartOver}
class="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700" class="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700"
> >
Start Over Start Over

View File

@@ -78,7 +78,7 @@
{#if photo.status === 'loading'} {#if photo.status === 'loading'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm"> <div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center"> <div class="h-48 bg-gray-200 flex items-center justify-center">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div <div
class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2" class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"
@@ -94,7 +94,7 @@
{:else if photo.status === 'success' && photo.objectUrl} {:else if photo.status === 'success' && photo.objectUrl}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative"> <div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative">
<div <div
class="h-48 bg-gray-100 flex items-center justify-center relative overflow-hidden" class="h-48 bg-gray-200 flex items-center justify-center relative overflow-hidden"
bind:this={imageContainer} bind:this={imageContainer}
> >
<img <img
@@ -152,7 +152,7 @@
</div> </div>
{:else if photo.status === 'error'} {:else if photo.status === 'error'}
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm"> <div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
<div class="h-48 bg-gray-100 flex items-center justify-center"> <div class="h-48 bg-gray-200 flex items-center justify-center">
<div class="flex flex-col items-center text-center p-4"> <div class="flex flex-col items-center text-center p-4">
<svg <svg
class="w-12 h-12 text-red-400 mb-2" class="w-12 h-12 text-red-400 mb-2"

View File

@@ -1,157 +1,97 @@
// PDF Layout Configuration Module // PDF Layout Configuration Module
// Centralized configuration for PDF generation layouts // Centralized configuration for PDF generation layouts, using millimeters.
export interface PDFDimensions { import {
pageWidth: number; PHOTO_DIMENSIONS,
pageHeight: number; TEXT_FIELD_LAYOUT,
margin: number; PHOTO_FIELD_LAYOUT
} } from './pdfSettings';
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)
export const MM_TO_PT = 72 / 25.4;
export interface GridLayout { export interface GridLayout {
cols: number; cols: number;
rows: number; rows: number;
cellWidth: number; cellWidth: number; // mm
cellHeight: number; cellHeight: number; // mm
} }
export interface TextPosition { // Calculate how many cards can fit on a page.
x: number; export function calculateGrid(
y: number; pageWidth: number,
size: number; pageHeight: number,
} margin: number,
cardWidth: number,
export interface PhotoPosition { cardHeight: number
x: number;
y: number;
width: number;
height: number;
}
export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
// A4 dimensions in points
export const PDF_DIMENSIONS: PDFDimensions = {
pageWidth: 595.28,
pageHeight: 841.89,
margin: 40
};
// Text PDF Layout (3x7 grid)
export const TEXT_PDF_GRID = {
cols: 3,
rows: 7
};
// Photo PDF Layout (3x5 grid)
export const PHOTO_PDF_GRID = {
cols: 3,
rows: 5
};
// Calculate grid layout
export function calculateGridLayout(
dimensions: PDFDimensions,
grid: { cols: number; rows: number }
): GridLayout { ): GridLayout {
const cellWidth = (dimensions.pageWidth - 2 * dimensions.margin) / grid.cols; const printableWidth = pageWidth - 2 * margin;
const cellHeight = (dimensions.pageHeight - 2 * dimensions.margin) / grid.rows; const printableHeight = pageHeight - 2 * margin;
const cols = Math.floor(printableWidth / cardWidth);
const rows = Math.floor(printableHeight / cardHeight);
return { return {
cols: grid.cols, cols,
rows: grid.rows, rows,
cellWidth, cellWidth: cardWidth,
cellHeight cellHeight: cardHeight
}; };
} }
// Text PDF Field Positions (relative to cell) // Helper function to get absolute position in points for pdf-lib
export const TEXT_FIELD_LAYOUT: TextFieldLayout = { export function getAbsolutePositionPt(
name: { cellX_mm: number,
x: 5, // 5pt from left edge of cell cellY_mm: number,
y: -15, // 15pt from top of cell (negative because PDF coords are bottom-up) pageHeight_mm: number,
size: 10 relativePos_mm: any
},
nationality: {
x: 5, // 5pt from left edge of cell
y: -29, // 29pt from top of cell (15 + 14 line height)
size: 10
},
birthday: {
x: 5, // 5pt from left edge of cell
y: -43, // 43pt from top of cell (15 + 14 + 14 line height)
size: 10
}
};
// Photo PDF Field Positions (relative to cell)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 10, // 10pt from left edge of cell
y: 40, // 40pt from bottom of cell
width: -20, // cell width minus 20pt (10pt margin on each side)
height: -60 // cell height minus 60pt (40pt bottom margin + 20pt top margin)
},
name: {
x: 10, // 10pt from left edge of cell
y: 20, // 20pt from bottom of cell
size: 10
}
};
// Helper function to get absolute position within a cell
export function getAbsolutePosition(
cellX: number,
cellY: number,
cellHeight: number,
relativePos: TextPosition
): { x: number; y: number; size: number } { ): { x: number; y: number; size: number } {
const absoluteX_mm = cellX_mm + relativePos_mm.x;
// pdf-lib Y-coordinate is from bottom, so we invert
const absoluteY_mm = pageHeight_mm - (cellY_mm + relativePos_mm.y);
return { return {
x: cellX + relativePos.x, x: absoluteX_mm * MM_TO_PT,
y: cellY + cellHeight + relativePos.y, // Convert relative Y to absolute y: absoluteY_mm * MM_TO_PT,
size: relativePos.size size: relativePos_mm.size // size is already in points
}; };
} }
// Helper function to get absolute photo dimensions // Helper function to get absolute photo dimensions in points for pdf-lib
export function getAbsolutePhotoDimensions( export function getAbsolutePhotoDimensionsPt(
cellX: number, cellX_mm: number,
cellY: number, cellY_mm: number,
cellWidth: number, pageHeight_mm: number,
cellHeight: number, relativePhoto_mm: any
relativePhoto: PhotoPosition
): { x: number; y: number; width: number; height: number } { ): { x: number; y: number; width: number; height: number } {
const absoluteX_mm = cellX_mm + relativePhoto_mm.x;
// pdf-lib Y-coordinate is from bottom, so we invert and account for height
const absoluteY_mm = pageHeight_mm - (cellY_mm + relativePhoto_mm.y + relativePhoto_mm.height);
return { return {
x: cellX + relativePhoto.x, x: absoluteX_mm * MM_TO_PT,
y: cellY + relativePhoto.y, y: absoluteY_mm * MM_TO_PT,
width: relativePhoto.width < 0 ? cellWidth + relativePhoto.width : relativePhoto.width, width: relativePhoto_mm.width * MM_TO_PT,
height: relativePhoto.height < 0 ? cellHeight + relativePhoto.height : relativePhoto.height height: relativePhoto_mm.height * MM_TO_PT
}; };
} }
// Border configuration // Border configuration
export const BORDER_CONFIG = { export const BORDER_CONFIG = {
color: { r: 0.8, g: 0.8, b: 0.8 }, color: { r: 0.8, g: 0.8, b: 0.8 },
width: 1 width: 1 // in points
}; };
// Text configuration // Text configuration
export const TEXT_CONFIG = { export const TEXT_CONFIG = {
color: { r: 0, g: 0, b: 0 }, color: { r: 0, g: 0, b: 0 },
lineHeight: 14 lineHeight: 14 // in points
}; };
// Placeholder text configuration // Placeholder text configuration
export const PLACEHOLDER_CONFIG = { export const PLACEHOLDER_CONFIG = {
text: 'Photo placeholder', text: 'Photo placeholder',
color: { r: 0.5, g: 0.5, b: 0.5 }, color: { r: 0.5, g: 0.5, b: 0.5 },
size: 8 size: 8 // in points
}; };

96
src/lib/pdfSettings.ts Normal file
View File

@@ -0,0 +1,96 @@
// User-configurable settings for PDF generation
export interface PageSettings {
pageWidth: number; // mm
pageHeight: number; // mm
margin: number; // mm
}
export interface CardDimensions {
width: number; // mm
height: number; // mm
}
// A4 Page dimensions in millimeters
export const PAGE_SETTINGS: PageSettings = {
pageWidth: 210,
pageHeight: 297,
margin: 10
};
// Dimensions for a single card in the text PDF.
// These dimensions will be used to calculate how many cards can fit on a page.
export const TEXT_CARD_DIMENSIONS: CardDimensions = {
width: 63,
height: 40
};
// Dimensions for a single card in the photo PDF.
export const PHOTO_CARD_DIMENSIONS: CardDimensions = {
width: 25,
height: 35
};
// Photo dimensions within the photo card
export const PHOTO_DIMENSIONS = {
width: 20, // mm
height: 35 // mm
};
export interface TextPosition {
x: number; // mm, relative to cell top-left
y: number; // mm, relative to cell top-left
size: number; // font size in points
}
export interface PhotoPosition {
x: number; // mm, relative to cell top-left
y: number; // mm, relative to cell top-left
width: number; // mm
height: number; // mm
}
export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
// Text PDF Field Positions (in mm, relative to cell top-left)
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
name: {
x: 2,
y: 5,
size: 10 // font size in points
},
nationality: {
x: 2,
y: 10,
size: 10
},
birthday: {
x: 2,
y: 15,
size: 10
}
};
// Photo PDF Field Positions (in mm, relative to cell top-left)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 2, // 2mm from left of cell
y: 2, // 2mm from top of cell
width: PHOTO_DIMENSIONS.width,
height: PHOTO_DIMENSIONS.height
},
name: {
x: 2, // 2mm from left of cell
y: PHOTO_DIMENSIONS.height + 0, // Below the photo + 5mm gap
size: 5 // font size in points
}
};