Added card types
All checks were successful
Build Docker image / build (push) Successful in 2m5s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 46s

This commit is contained in:
Roman Krček
2025-08-11 18:30:07 +02:00
parent 1a2329b6c1
commit a9dc5888e6
17 changed files with 484 additions and 382 deletions

View File

@@ -0,0 +1,31 @@
import type { Card } from './types';
// User-configurable settings for PDF generation
export const ESNCard2026: Card = {
name: 'ESNcard 2026',
image: '/cards/2026.webp',
textCard: {
width: 50, // mm
height: 35 // mm
},
photoCard: {
width: 32, // mm
height: 45 // mm
},
photo: {
width: 28, // mm
height: 38 // mm
},
textFields: {
name: { x: 3, y: 5, size: 9 },
nationality: { x: 3, y: 14, size: 9 },
birthday: { x: 33, y: 14, size: 9 },
studiesAt: { x: 3, y: 23, size: 9 },
esnSection: { x: 3, y: 32, size: 9 },
validityStart: { x: 33, y: 32, size: 9 }
},
photoFields: {
photo: { x: 2, y: 2, width: 28, height: 38 },
name: { x: 2, y: 42, size: 7 }
}
};

View File

@@ -0,0 +1,31 @@
import type { Card } from './types';
// User-configurable settings for PDF generation
export const ESNCardAnniversary: Card = {
name: 'ESNcard Anniversary',
image: '/cards/esncard_anniversary.png',
textCard: {
width: 45, // mm
height: 30 // mm
},
photoCard: {
width: 29, // mm
height: 41 // mm
},
photo: {
width: 26, // mm
height: 36 // mm
},
textFields: {
name: { x: 2, y: 4, size: 8 },
nationality: { x: 2, y: 12, size: 8 },
birthday: { x: 30, y: 12, size: 8 },
studiesAt: { x: 2, y: 20, size: 8 },
esnSection: { x: 2, y: 28, size: 8 },
validityStart: { x: 30, y: 28, size: 8 }
},
photoFields: {
photo: { x: 2, y: 2, width: 26, height: 36 },
name: { x: 2, y: 40, size: 6 }
}
};

5
src/lib/cards/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import { ESNCardAnniversary } from './esncard_anniversary';
import { ESNCard2026 } from './esncard_2026';
import type { Card } from './types';
export const cardTypes: Card[] = [ESNCardAnniversary, ESNCard2026];

View File

@@ -0,0 +1,46 @@
export interface CardDimensions {
width: number; // mm
height: number; // mm
}
export interface PhotoDimensions {
width: number; // mm
height: number; // 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;
studiesAt: TextPosition;
esnSection: TextPosition;
validityStart: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
export interface Card {
name: string;
image: string;
textCard: CardDimensions;
photoCard: CardDimensions;
photo: PhotoDimensions;
textFields: TextFieldLayout;
photoFields: PhotoFieldLayout;
}

View File

@@ -1,59 +1,71 @@
<script lang="ts"> <script lang="ts">
import { currentStep } from '$lib/stores.js'; import { currentStep, steps as stepNames, currentStepName } from '$lib/stores';
import StepAuth from './wizard/StepAuth.svelte'; import Splash from './Splash.svelte';
import StepSheetSearch from './wizard/StepSheetSearch.svelte'; import StepAuth from './wizard/StepAuth.svelte';
import StepColumnMap from './wizard/StepColumnMap.svelte'; import StepSheetSearch from './wizard/StepSheetSearch.svelte';
import StepRowFilter from './wizard/StepRowFilter.svelte'; import StepColumnMap from './wizard/StepColumnMap.svelte';
import StepCardDetails from './wizard/StepCardDetails.svelte'; import StepRowFilter from './wizard/StepRowFilter.svelte';
import StepGallery from './wizard/StepGallery.svelte'; import StepCardDetails from './wizard/StepCardDetails.svelte';
import StepGenerate from './wizard/StepGenerate.svelte'; import StepCardSelect from './wizard/StepCardSelect.svelte';
import StepGallery from './wizard/StepGallery.svelte';
import StepGenerate from './wizard/StepGenerate.svelte';
const steps = [ const stepComponents = {
StepAuth, splash: Splash,
StepSheetSearch, auth: StepAuth,
StepColumnMap, search: StepSheetSearch,
StepRowFilter, mapping: StepColumnMap,
StepCardDetails, validation: StepRowFilter,
StepGallery, 'card-details': StepCardDetails,
StepGenerate 'card-select': StepCardSelect,
]; gallery: StepGallery,
generate: StepGenerate
};
const stepTitles = [ const stepTitles = {
'Authenticate', splash: 'Welcome',
'Select Sheet', auth: 'Authenticate',
'Map Columns', search: 'Select Sheet',
'Filter Rows', mapping: 'Map Columns',
'Enter Card Details', validation: 'Filter Rows',
'Preview Gallery', 'card-details': 'Enter Card Details',
'Generate Cards' 'card-select': 'Select Card Type',
]; gallery: 'Preview Gallery',
generate: 'Generate Cards'
};
let currentTitle = $derived(stepTitles[$currentStepName]);
let currentComponent = $derived(stepComponents[$currentStepName]);
let currentStepIndex = $derived(stepNames.indexOf($currentStepName));
</script> </script>
<div class="bg-gray-100 min-h-screen p-4"> <div class="bg-gray-100 min-h-screen p-4">
<div class="container mx-auto max-w-4xl pb-10"> <div class="container mx-auto max-w-4xl pb-10">
<!-- Progress indicator --> {#if $currentStepName !== 'splash'}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6"> <!-- Progress indicator -->
<div class="flex items-center justify-between mb-4"> <div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h1 class="text-2xl font-bold text-gray-800"> <div class="flex items-center justify-between mb-4">
{stepTitles[$currentStep - 1]} <h1 class="text-2xl font-bold text-gray-800">
</h1> {currentTitle}
<span class="text-sm text-gray-500"> </h1>
Step {$currentStep} of {steps.length} <span class="text-sm text-gray-500">
</span> Step {currentStepIndex} of {stepNames.length - 1}
</div> </span>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {($currentStep / steps.length) * 100}%"
></div>
</div>
</div>
<!-- Step content --> <!-- Progress bar -->
<div class="bg-white rounded-lg shadow-sm"> <div class="w-full bg-gray-200 rounded-full h-2">
<svelte:component this={steps[$currentStep - 1]} /> <div
</div> class="bg-blue-600 h-2 rounded-full transition-all duration-300"
</div> style="width: {(currentStepIndex / (stepNames.length - 1)) * 100}%"
></div>
</div>
</div>
{/if}
<!-- Step content -->
<div class="bg-white rounded-lg shadow-sm">
<svelte:component this={currentComponent} />
</div>
</div>
</div> </div>

View File

@@ -95,7 +95,7 @@
{currentStep} {currentStep}
onForward={handleContinue} onForward={handleContinue}
textBack="Back to Row Selection" textBack="Back to Row Selection"
textForwardEnabled="Continue to Photo Review" textForwardEnabled="Continue to Card Selection"
textForwardDisabled="Please fill out all fields" textForwardDisabled="Please fill out all fields"
/> />
</div> </div>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { onMount } from 'svelte';
import { currentStep, selectedCard } from '$lib/stores';
import { cardTypes } from '$lib/cards';
import type { Card } from '$lib/cards/types';
import Navigator from './subcomponents/Navigator.svelte';
let selected: Card | null = $state(null);
onMount(() => {
const savedCardName = localStorage.getItem('selectedCardName');
if (savedCardName) {
const foundCard = cardTypes.find((c) => c.name === savedCardName);
if (foundCard) {
selected = foundCard;
selectedCard.set(foundCard);
}
}
});
function selectCard(card: Card) {
selected = card;
selectedCard.set(card);
localStorage.setItem('selectedCardName', card.name);
}
function onNext() {
if (selected) {
currentStep.set($currentStep + 1);
}
}
function onBack() {
currentStep.set($currentStep - 1);
}
</script>
<div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">Select Card Type</h2>
<p class="text-sm text-gray-700 mb-4">
Choose the type of card you want to generate. This will determine the layout and dimensions of the final PDFs.
</p>
</div>
<!-- Card Type Selector -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{#each cardTypes as card (card.name)}
<button
class="relative rounded-lg border-2 p-2 transition-all duration-200"
class:border-blue-600={selected?.name === card.name}
class:border-gray-200={selected?.name !== card.name}
class:shadow-lg={selected?.name === card.name}
onclick={() => selectCard(card)}
>
<img src={card.image} alt={card.name} class="w-full h-auto rounded-md mb-2" />
<p class="text-sm font-medium text-center text-gray-800">{card.name}</p>
{#if selected?.name === card.name}
<div
class="absolute top-2 right-2 bg-blue-600 text-white rounded-full w-6 h-6 flex items-center justify-center"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
{/if}
</button>
{/each}
</div>
<Navigator onForward={onNext} onBack={onBack} nextDisabled={!selected} />
</div>
</div>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { PHOTO_DIMENSIONS } from '$lib/pdfSettings'; import { columnMapping, sheetData, currentStep, pictures, cropRects, selectedCard } from '$lib/stores';
import { columnMapping, sheetData, currentStep, pictures, cropRects } from '$lib/stores';
import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl, ensureToken } from '$lib/google'; import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl, ensureToken } from '$lib/google';
import Navigator from './subcomponents/Navigator.svelte'; import Navigator from './subcomponents/Navigator.svelte';
import PhotoCard from './subcomponents/PhotoCard.svelte'; import PhotoCard from './subcomponents/PhotoCard.svelte';
@@ -340,8 +339,9 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX; const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX;
const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY; const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY;
// Use the photo card aspect ratio from PDF settings (width / height) // Use the photo card aspect ratio from the selected card
const cropRatio = PHOTO_DIMENSIONS.width / PHOTO_DIMENSIONS.height; const photoDimensions = $selectedCard!.photo;
const cropRatio = photoDimensions.width / photoDimensions.height;
const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0'); const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0');
const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0'); const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0');
const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5'); const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5');
@@ -581,6 +581,7 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
{#each photos as photo, index} {#each photos as photo, index}
<PhotoCard <PhotoCard
{photo} {photo}
photoDimensions={$selectedCard!.photo}
onCropUpdated={(e) => handleCropUpdate(index, e)} onCropUpdated={(e) => handleCropUpdate(index, e)}
onRetry={() => retryPhoto(index)} onRetry={() => retryPhoto(index)}
/> />
@@ -593,7 +594,7 @@ async function createPreviewBlob(original: Blob, maxSide = 1200, quality = 0.85)
<Navigator <Navigator
canProceed={canProceed()} canProceed={canProceed()}
{currentStep} {currentStep}
textBack="Back to Card Details" textBack="Back to Card Selection"
textForwardDisabled="Waiting from photos" textForwardDisabled="Waiting from photos"
textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`} textForwardEnabled={`Generate ${photos.filter((p) => p.status === 'success' && p.cropData).length} Cards`}
/> />

View File

@@ -1,25 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { sheetData, currentStep, pictures, cropRects, cardDetails } from '$lib/stores'; import { sheetData, currentStep, pictures, cropRects, cardDetails, selectedCard } from '$lib/stores';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import type { Card } from '$lib/cards/types';
import { PDFDocument, StandardFonts, rgb, type PDFPage } from 'pdf-lib';
import * as fontkit from 'fontkit'; import * as fontkit from 'fontkit';
import { clear } from 'idb-keyval'; import { clear } from 'idb-keyval';
import {
BORDER_CONFIG,
TEXT_CONFIG,
calculateGrid,
getAbsolutePositionPt,
getAbsolutePhotoDimensionsPt,
getImageBlob,
MM_TO_PT
} from '$lib/pdfLayout';
import { import {
PAGE_SETTINGS, BORDER_CONFIG,
TEXT_CARD_DIMENSIONS, TEXT_CONFIG,
PHOTO_CARD_DIMENSIONS, calculateGrid,
TEXT_FIELD_LAYOUT, getAbsolutePositionPt,
PHOTO_FIELD_LAYOUT getAbsolutePhotoDimensionsPt,
} from '$lib/pdfSettings'; getImageBlob,
MM_TO_PT
} from '$lib/pdfLayout';
import { PAGE_SETTINGS } from '$lib/pdfSettings';
import type { PageSettings } from '$lib/pdfSettings';
import Navigator from './subcomponents/Navigator.svelte';
type FileGenerationState = 'idle' | 'generating' | 'done' | 'error'; type FileGenerationState = 'idle' | 'generating' | 'done' | 'error';
@@ -70,13 +67,10 @@
} }
onMount(() => { onMount(() => {
handleGenerateAll();
// Add event listener for page unload // Add event listener for page unload
window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload);
// Start generation automatically when the component mounts
handleGenerate('esncards_text.pdf');
handleGenerate('esncards_photos.pdf');
// Cleanup function when component unmounts // Cleanup function when component unmounts
return () => { return () => {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
@@ -84,6 +78,22 @@
}; };
}); });
async function handleGenerateAll() {
if (!$selectedCard) return;
// Reset file states and revoke old URLs
files.forEach((f) => {
if (f.url) URL.revokeObjectURL(f.url);
});
files = JSON.parse(JSON.stringify(initialFiles));
// Generate both
await handleGenerate('esncards_text.pdf');
await handleGenerate('esncards_photos.pdf');
}
const generationStarted = $derived(files.some((f) => f.state !== 'idle'));
// Load Roboto font // Load Roboto font
async function loadRobotoFont() { async function loadRobotoFont() {
try { try {
@@ -110,15 +120,15 @@
} }
// Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks // Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks
function drawHMarks(page: any, font: any) { function drawHMarks(page: PDFPage, font: any, pageSettings: PageSettings) {
const color = rgb(0, 0, 0); // pure black const color = rgb(0, 0, 0); // pure black
const lengthPt = 100 * MM_TO_PT; // 10 cm const lengthPt = 100 * MM_TO_PT; // 10 cm
const tickLenPt = 2 * MM_TO_PT; // 2 mm tick const tickLenPt = 2 * MM_TO_PT; // 2 mm tick
const strokePt = 0.7; // visual thickness const strokePt = 0.7; // visual thickness
// Top margin center // Top margin center
const centerTopX = (PAGE_SETTINGS.pageWidth / 2) * MM_TO_PT; const centerTopX = (pageSettings.pageWidth / 2) * MM_TO_PT;
const centerTopY = (PAGE_SETTINGS.pageHeight - PAGE_SETTINGS.margin / 2) * MM_TO_PT; const centerTopY = (pageSettings.pageHeight - pageSettings.margin / 2) * MM_TO_PT;
const leftTopX = centerTopX - lengthPt / 2; const leftTopX = centerTopX - lengthPt / 2;
const rightTopX = centerTopX + lengthPt / 2; const rightTopX = centerTopX + lengthPt / 2;
@@ -147,7 +157,6 @@
color color
}); });
// Label under the top bar, centered // Label under the top bar, centered
const label = 'Print gauge - if not 10 cm long, the page is not printed correctly!'; const label = 'Print gauge - if not 10 cm long, the page is not printed correctly!';
const labelSize = 7; const labelSize = 7;
@@ -163,8 +172,8 @@
}); });
// Left margin center (vertical bar) // Left margin center (vertical bar)
const centerLeftX = (PAGE_SETTINGS.margin / 2) * MM_TO_PT; const centerLeftX = (pageSettings.margin / 2) * MM_TO_PT;
const centerLeftY = (PAGE_SETTINGS.pageHeight / 2) * MM_TO_PT; const centerLeftY = (pageSettings.pageHeight / 2) * MM_TO_PT;
// Vertical bar (left) // Vertical bar (left)
page.drawRectangle({ page.drawRectangle({
@@ -328,13 +337,6 @@
const timestamp = getTimestamp(); const timestamp = getTimestamp();
const baseName = fileName.replace('.pdf', ''); const baseName = fileName.replace('.pdf', '');
fileToUpdate.downloadName = `${baseName}_${timestamp}.pdf`; fileToUpdate.downloadName = `${baseName}_${timestamp}.pdf`;
// Check if both PDFs are done, then clear sensitive data
const allDone = files.every((f) => f.state === 'done' || f.state === 'error');
if (allDone) {
console.log('All PDFs generated, clearing sensitive data...');
await clearSensitiveData();
}
} catch (error: any) { } catch (error: any) {
console.error(`PDF generation failed for ${fileName}:`, error); console.error(`PDF generation failed for ${fileName}:`, error);
fileToUpdate.state = 'error'; fileToUpdate.state = 'error';
@@ -343,6 +345,9 @@
} }
async function generateTextPDF() { async function generateTextPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit); pdfDoc.registerFontkit(fontkit);
@@ -355,8 +360,8 @@
PAGE_SETTINGS.pageWidth, PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin, PAGE_SETTINGS.margin,
TEXT_CARD_DIMENSIONS.width, card.textCard.width,
TEXT_CARD_DIMENSIONS.height card.textCard.height
); );
const pageDimsPt = { const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT, width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
@@ -364,7 +369,7 @@
}; };
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font); drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0; let currentRow = 0;
let currentCol = 0; let currentCol = 0;
@@ -392,14 +397,12 @@
const birthdayFmt = formatDateDDMMYY(birthday); const birthdayFmt = formatDateDDMMYY(birthday);
const validityStartFmt = formatDateDDMMYY(validityStart); const validityStartFmt = formatDateDDMMYY(validityStart);
console.log(birthday, validityStart)
// Row 1: Name // Row 1: Name
const namePos = getAbsolutePositionPt( const namePos = getAbsolutePositionPt(
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.name card.textFields.name
); );
page.drawText(`${name} ${surname}`, { page.drawText(`${name} ${surname}`, {
...namePos, ...namePos,
@@ -412,7 +415,7 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.nationality card.textFields.nationality
); );
page.drawText(`${nationality}`, { page.drawText(`${nationality}`, {
...natPos, ...natPos,
@@ -425,16 +428,16 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.birthday card.textFields.birthday
); );
// Row 3: Studies at // Row 3: Studies at
const studiesPos = getAbsolutePositionPt( const studiesPos = getAbsolutePositionPt(
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.studiesAt card.textFields.studiesAt
); );
page.drawText(`${studiesAt}` , { page.drawText(`${studiesAt}`, {
...studiesPos, ...studiesPos,
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)
@@ -445,9 +448,9 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.esnSection card.textFields.esnSection
); );
page.drawText(`${esnSection}` , { page.drawText(`${esnSection}`, {
...sectionPos, ...sectionPos,
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)
@@ -458,9 +461,9 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.validityStart card.textFields.validityStart
); );
page.drawText(`${validityStartFmt}` , { page.drawText(`${validityStartFmt}`, {
...validPos, ...validPos,
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)
@@ -488,7 +491,7 @@
currentRow++; currentRow++;
if (currentRow >= gridLayout.rows) { if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font); drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0; currentRow = 0;
} }
} }
@@ -498,6 +501,9 @@
} }
async function generatePhotoPDF() { async function generatePhotoPDF() {
const card = $selectedCard;
if (!card) throw new Error('No card type selected');
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit); pdfDoc.registerFontkit(fontkit);
@@ -510,8 +516,8 @@
PAGE_SETTINGS.pageWidth, PAGE_SETTINGS.pageWidth,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PAGE_SETTINGS.margin, PAGE_SETTINGS.margin,
PHOTO_CARD_DIMENSIONS.width, card.photoCard.width,
PHOTO_CARD_DIMENSIONS.height card.photoCard.height
); );
const pageDimsPt = { const pageDimsPt = {
width: PAGE_SETTINGS.pageWidth * MM_TO_PT, width: PAGE_SETTINGS.pageWidth * MM_TO_PT,
@@ -519,7 +525,7 @@
}; };
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font); drawHMarks(page, font, PAGE_SETTINGS);
let currentRow = 0; let currentRow = 0;
let currentCol = 0; let currentCol = 0;
@@ -537,7 +543,7 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.photo card.photoFields.photo
); );
const pictureUrl = row.pictureUrl; const pictureUrl = row.pictureUrl;
@@ -607,7 +613,7 @@
cellX_mm, cellX_mm,
cellY_mm, cellY_mm,
PAGE_SETTINGS.pageHeight, PAGE_SETTINGS.pageHeight,
PHOTO_FIELD_LAYOUT.name card.photoFields.name
); );
page.drawText(`${name} ${surname}`, { page.drawText(`${name} ${surname}`, {
...namePos, ...namePos,
@@ -622,7 +628,7 @@
currentRow++; currentRow++;
if (currentRow >= gridLayout.rows) { if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]); page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font); drawHMarks(page, font, PAGE_SETTINGS);
currentRow = 0; currentRow = 0;
} }
} }
@@ -667,148 +673,127 @@
<div class="p-6"> <div class="p-6">
<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">Generating PDFs...</h2>
Generating PDFs
</h2>
<p class="text-sm text-gray-700 mb-4"> <p class="text-sm text-gray-700 mb-4">
Create two PDF documents: one with text data and one with photos. Your PDF documents are being created. Please wait a moment.
</p> </p>
</div> </div>
<!-- Summary --> {#if files.some((f) => f.state === 'generating')}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4"> <div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Generation Summary</h3> <div class="flex items-center justify-between">
<div class="flex items-center">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div
<div class="text-center"> class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
<div class="text-2xl font-bold text-gray-900"> ></div>
{$sheetData.filter((row) => row._valid).length} <span class="text-sm text-blue-800"> Processing... </span>
</div> </div>
<div class="text-gray-600">Records to Process</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{files.length}</div>
<div class="text-gray-600">PDFs to Generate</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">
{files.filter((f) => f.state === 'done').length}
</div>
<div class="text-gray-600">Files Ready</div>
</div> </div>
</div> </div>
</div> {/if}
<!-- Generated Files --> {#if generationStarted}
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6"> <!-- Generated Files -->
<div class="p-4 border-b border-gray-200"> <div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
<h3 class="text-lg font-medium text-gray-900">Available Downloads</h3> <div class="p-4 border-b border-gray-200">
</div> <h3 class="text-lg font-medium text-gray-900">Available Downloads</h3>
</div>
<div class="divide-y divide-gray-200"> <div class="divide-y divide-gray-200">
{#each files as file (file.name)} {#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">
{#if file.displayName === 'Text PDF'} {#if file.displayName === 'Text PDF'}
<svg <svg
class="w-8 h-8 text-red-600 mr-3" class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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> <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> <polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="18" x2="12" y2="12"></line> <line x1="12" y1="18" x2="12" y2="12"></line>
<line x1="9" y1="12" x2="15" y2="12"></line> <line x1="9" y1="12" x2="15" y2="12"></line>
</svg> </svg>
{:else if file.displayName === 'Photos PDF'} {:else if file.displayName === 'Photos PDF'}
<svg <svg
class="w-8 h-8 text-red-600 mr-3" class="w-8 h-8 text-red-600 mr-3"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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> <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> <polyline points="14 2 14 8 20 8"></polyline>
<circle cx="12" cy="13" r="2"></circle> <circle cx="12" cy="13" r="2"></circle>
<path d="M15 17.5c-1.5-1-4.5-1-6 0"></path> <path d="M15 17.5c-1.5-1-4.5-1-6 0"></path>
</svg> </svg>
{/if}
<div>
<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>
{:else if file.state === 'error'}
<p class="text-xs text-red-500">Error: {file.error}</p>
{/if} {/if}
</div> <div>
</div> <h4 class="text-sm font-medium text-gray-900">{file.displayName}</h4>
{#if file.state === 'done' && file.size}
{#if file.state === 'idle'} <p class="text-xs text-gray-500">{formatFileSize(file.size)}</p>
<button {:else if file.state === 'error'}
onclick={() => handleGenerate(file.name)} <p class="text-xs text-red-500">Error: {file.error}</p>
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700" {:else if file.state === 'generating'}
> <p class="text-xs text-gray-500">Generating...</p>
Generate {:else if file.state === 'idle'}
</button> <p class="text-xs text-gray-500">Waiting...</p>
{:else if file.state === 'generating'} {/if}
<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> </div>
</button> </div>
{:else if file.state === 'done'}
<button {#if file.state === 'idle'}
onclick={() => downloadFile(file)} <div class="px-4 py-2 text-gray-500 text-sm">Waiting...</div>
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700" {:else if file.state === 'generating'}
> <button
Download disabled
</button> aria-label="Generating..."
{:else if file.state === 'error'} class="px-4 py-2 bg-gray-400 text-white rounded-md text-sm font-medium cursor-wait"
<button >
onclick={() => handleGenerate(file.name)} <div class="flex items-center justify-center">
class="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700" <div
> class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
Retry ></div>
</button> </div>
{/if} </button>
</div> {:else if file.state === 'done'}
{/each} <button
onclick={() => downloadFile(file)}
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Download
</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>
{/each}
</div>
</div> </div>
</div> {/if}
<!-- Navigation --> <!-- Navigation -->
<div class="flex justify-between"> <div class="mt-10">
<button <Navigator
onclick={() => currentStep.set(6)} {currentStep}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300" onForward={resetAndStartOver}
> canProceed={files.some((f) => f.state === 'done' || f.state === 'error')}
Back to Gallery textBack="Back to Gallery"
</button> textForwardEnabled="Start Over"
textForwardDisabled="Generate PDFs to Continue"
{#if files.some((f) => f.state === 'done' || f.state === 'error')} hideForwardUntilProceedable={true}
<button />
onclick={resetAndStartOver}
class="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700"
>
Start Over
</button>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,56 +1,61 @@
<script lang="ts"> <script lang="ts">
let { let {
canProceed, canProceed = true,
currentStep, currentStep,
textBack, textBack = 'Back',
textForwardDisabled, textForwardDisabled = 'Next',
textForwardEnabled, textForwardEnabled = 'Next',
onBack, onBack,
onForward onForward,
nextDisabled = false
} = $props<{ } = $props<{
canProceed: boolean; canProceed?: boolean;
currentStep: any; currentStep?: any;
textBack: string; textBack?: string;
textForwardDisabled: string; textForwardDisabled?: string;
textForwardEnabled: string; textForwardEnabled?: string;
onBack?: () => void | null; onBack?: () => void;
onForward?: () => void | null; onForward?: () => void;
nextDisabled?: boolean;
}>(); }>();
async function handleBack() { async function handleBack() {
if (onBack) { if (onBack) {
// Allow custom back logic if provided
await onBack(); await onBack();
} else if (currentStep) {
currentStep.set($currentStep - 1);
} }
currentStep.set($currentStep - 1);
} }
async function handleForward() { async function handleForward() {
if (onForward) { if (onForward) {
// Allow custom forward logic if provided
await onForward(); await onForward();
} }
currentStep.set($currentStep + 1); if (currentStep) {
currentStep.set($currentStep + 1);
}
} }
</script> </script>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:justify-between">
<button {#if onBack || currentStep}
onclick={handleBack} <button
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto" onclick={handleBack}
> class="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300 sm:w-auto"
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<span>{textBack}</span> </svg>
</button> <span>{textBack}</span>
</button>
{/if}
<button <button
onclick={handleForward} onclick={handleForward}
disabled={!canProceed} disabled={!canProceed || nextDisabled}
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto" class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400 sm:w-auto"
> >
<span>{canProceed ? textForwardEnabled : textForwardDisabled}</span> <span>{canProceed && !nextDisabled ? textForwardEnabled : textForwardDisabled}</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PhotoDimensions } from '$lib/cards/types';
import PhotoCrop from './PhotoCrop.svelte'; import PhotoCrop from './PhotoCrop.svelte';
let { photo, onCropUpdated, onRetry } = $props<{ let { photo, onCropUpdated, onRetry, photoDimensions } = $props<{
photo: { photo: {
name: string; name: string;
url: string; url: string;
@@ -13,6 +14,7 @@
}; };
onCropUpdated: (detail: any) => void; onCropUpdated: (detail: any) => void;
onRetry: () => void; onRetry: () => void;
photoDimensions: PhotoDimensions;
}>(); }>();
let showCropper = $state(false); let showCropper = $state(false);
@@ -145,6 +147,7 @@
imageUrl={photo.objectUrl} imageUrl={photo.objectUrl}
personName={photo.name} personName={photo.name}
initialCropData={photo.cropData} initialCropData={photo.cropData}
{photoDimensions}
onClose={() => (showCropper = false)} onClose={() => (showCropper = false)}
onCropUpdated={handleCropUpdated} onCropUpdated={handleCropUpdated}
/> />

View File

@@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { PHOTO_DIMENSIONS } from '$lib/pdfSettings'; import type { PhotoDimensions } from '$lib/cards/types';
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{ let {
imageUrl,
personName,
initialCropData,
onCropUpdated,
onClose,
photoDimensions
} = $props<{
imageUrl: string; imageUrl: string;
personName: string; personName: string;
initialCropData?: { x: number; y: number; width: number; height: number }; initialCropData?: { x: number; y: number; width: number; height: number };
@@ -10,6 +17,7 @@
cropData: { x: number; y: number; width: number; height: number }; cropData: { x: number; y: number; width: number; height: number };
}) => void; }) => void;
onClose: () => void; onClose: () => void;
photoDimensions: PhotoDimensions;
}>(); }>();
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
@@ -35,8 +43,8 @@
let canvasWidth = 600; let canvasWidth = 600;
let canvasHeight = 400; let canvasHeight = 400;
// Use the photo card aspect ratio from PDF settings (width / height) // Use the photo card aspect ratio from the selected card's dimensions
const cropRatio = PHOTO_DIMENSIONS.width / PHOTO_DIMENSIONS.height; const cropRatio = photoDimensions.width / photoDimensions.height;
onMount(() => { onMount(() => {
ctx = canvas.getContext('2d')!; ctx = canvas.getContext('2d')!;

View File

@@ -1,11 +1,3 @@
// PDF Layout Configuration Module
// Centralized configuration for PDF generation layouts, using millimeters.
import {
PHOTO_DIMENSIONS,
TEXT_FIELD_LAYOUT,
PHOTO_FIELD_LAYOUT
} from './pdfSettings';
import { get } from 'idb-keyval'; import { get } from 'idb-keyval';
// Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm) // Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm)

View File

@@ -6,111 +6,9 @@ export interface PageSettings {
margin: number; // mm margin: number; // mm
} }
export interface CardDimensions {
width: number; // mm
height: number; // mm
}
// A4 Page dimensions in millimeters // A4 Page dimensions in millimeters
export const PAGE_SETTINGS: PageSettings = { export const PAGE_SETTINGS: PageSettings = {
pageWidth: 210, pageWidth: 210,
pageHeight: 297, pageHeight: 297,
margin: 15 margin: 15
}; };
// 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: 45,
height: 30
};
// Dimensions for a single card in the photo PDF.
export const PHOTO_CARD_DIMENSIONS: CardDimensions = {
width: 29,
height: 41
};
// Photo dimensions within the photo card
export const PHOTO_DIMENSIONS = {
width: 26, // mm
height: 36 // 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;
studiesAt: TextPosition;
esnSection: TextPosition;
validityStart: TextPosition;
}
export interface PhotoFieldLayout {
photo: PhotoPosition;
name: TextPosition;
}
const FONT_SIZE = 8; // pt
// Text PDF Field Positions (in mm, relative to cell top-left)
export const TEXT_FIELD_LAYOUT: TextFieldLayout = {
name: {
x: 2,
y: 4,
size: FONT_SIZE
},
nationality: {
x: 2,
y: 12,
size: FONT_SIZE
},
birthday: {
x: 30,
y: 12,
size: FONT_SIZE
},
studiesAt: {
x: 2,
y: 20,
size: FONT_SIZE
},
esnSection: {
x: 2,
y: 28,
size: FONT_SIZE
},
validityStart: {
x: 30,
y: 28,
size: FONT_SIZE
}
};
// Photo PDF Field Positions (in mm, relative to cell top-left)
export const PHOTO_FIELD_LAYOUT: PhotoFieldLayout = {
photo: {
x: 2,
y: 2,
width: PHOTO_DIMENSIONS.width,
height: PHOTO_DIMENSIONS.height
},
name: {
x: 2,
y: PHOTO_DIMENSIONS.height + 4,
size: 6
}
};

View File

@@ -95,6 +95,10 @@ export const selectedSheet = writable<SheetInfoType>({ id: '', name: '', webView
// Card details for generation // Card details for generation
export const cardDetails = writable<CardDetailsType | null>(null); export const cardDetails = writable<CardDetailsType | null>(null);
// Selected card type for generation
import type { Card } from '$lib/cards/types';
export const selectedCard = writable<Card | null>(null);
// Wizard state management // Wizard state management
export const currentStep = writable<number>(0); export const currentStep = writable<number>(0);
@@ -104,6 +108,8 @@ export const steps = [
'search', 'search',
'mapping', 'mapping',
'validation', 'validation',
'card-details',
'card-select',
'gallery', 'gallery',
'generate' 'generate'
] as const; ] as const;

BIN
static/cards/2026.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB