Added card types
This commit is contained in:
31
src/lib/cards/esncard_2026.ts
Normal file
31
src/lib/cards/esncard_2026.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
};
|
||||||
31
src/lib/cards/esncard_anniversary.ts
Normal file
31
src/lib/cards/esncard_anniversary.ts
Normal 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
5
src/lib/cards/index.ts
Normal 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];
|
||||||
46
src/lib/cards/types/index.ts
Normal file
46
src/lib/cards/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
79
src/lib/components/wizard/StepCardSelect.svelte
Normal file
79
src/lib/components/wizard/StepCardSelect.svelte
Normal 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>
|
||||||
@@ -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`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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')!;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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
BIN
static/cards/2026.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
static/cards/esncard_anniversary.png
Normal file
BIN
static/cards/esncard_anniversary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
Reference in New Issue
Block a user