Compare commits
5 Commits
39b15f1314
...
1e96668e48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e96668e48 | ||
|
|
923300e49b | ||
|
|
1aa6cd53fa | ||
|
|
dc1edaae84 | ||
|
|
1fde370890 |
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -14,3 +14,4 @@
|
|||||||
- Remain consistent in styling and code structure.
|
- Remain consistent in styling and code structure.
|
||||||
- Avoid unncessary iterations. If problems is mostly solved, stop.
|
- Avoid unncessary iterations. If problems is mostly solved, stop.
|
||||||
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
|
- Split big components into subcomponents. Always create smaller subcomponents for better context management later.
|
||||||
|
- Do not do what you're not being asked. Stick to scope of my request.
|
||||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -19,6 +19,8 @@
|
|||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
|
"p-queue": "^8.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
@@ -1013,6 +1015,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -1187,6 +1195,12 @@
|
|||||||
"version": "8.0.3",
|
"version": "8.0.3",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1443,6 +1457,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-queue": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"p-timeout": "^6.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-timeout": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
"heic-convert": "^2.1.0",
|
"heic-convert": "^2.1.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
|
"p-queue": "^8.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/fontkit.d.ts
vendored
Normal file
1
src/fontkit.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module 'fontkit';
|
||||||
@@ -1,80 +1,123 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { currentStep } from '$lib/stores.js';
|
import { currentStep } from '$lib/stores.js';
|
||||||
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google';
|
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google';
|
||||||
|
import Navigator from './subcomponents/Navigator.svelte';
|
||||||
function proceed() {
|
|
||||||
currentStep.set(2);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="max-w-md mx-auto text-center">
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
|
<h2 class="mb-2 text-xl font-semibold text-gray-900">Connect to Google</h2>
|
||||||
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
|
<p class="text-sm text-gray-700">
|
||||||
<path d="M12.017 11.215c-3.573-2.775-9.317-.362-9.317 4.686C2.7 21.833 6.943 24 12.017 24c5.076 0 9.319-2.167 9.319-8.099 0-5.048-5.744-7.461-9.319-4.686z"/>
|
Sign in with your Google account to access your Google Sheets and Google Drive for photo
|
||||||
<path d="M20.791 5.016c-1.395-1.395-3.61-1.428-5.024-.033l-1.984 1.984v-.002L12.017 8.73 10.25 6.965l-1.984-1.984c-1.414-1.395-3.629-1.362-5.024.033L1.498 6.758c-1.438 1.438-1.438 3.77 0 5.208l1.744 1.744c1.395 1.395 3.61 1.428 5.024.033l1.984-1.984v.002L12.017 9.996l1.767 1.765 1.984 1.984c1.414 1.395 3.629 1.362 5.024-.033l1.744-1.744c1.438-1.438 1.438-3.77 0-5.208L20.791 5.016z"/>
|
downloads.
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Connect to Google
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-700 leading-relaxed mb-6">
|
|
||||||
Sign in with your Google account to access your Google Sheets and Google Drive for photo downloads.
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 mb-6 space-y-1">
|
<div class="grid gap-8 md:grid-cols-2">
|
||||||
<p>Required permissions:</p>
|
<!-- Left Column: Information -->
|
||||||
<p>• View your Google Spreadsheets</p>
|
<div class="space-y-6 text-gray-700">
|
||||||
<p>• View and manage the files in your Google Drive</p>
|
<div>
|
||||||
|
<h4 class="font-semibold text-gray-900">Google Sheets Integration</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
Seamlessly import your data without the hassle of manual copy-pasting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-gray-900">Google Drive Access</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
Automatically download photos for your cards directly from your Google Drive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-gray-900">Privacy & Security</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
Your data is processed entirely in your browser. Nothing is ever uploaded to or stored on
|
||||||
|
our servers. We only request read-only access. All of the data is then removed from your browser
|
||||||
|
when the work is finished.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Action -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-8"
|
||||||
|
>
|
||||||
{#if $isSignedIn}
|
{#if $isSignedIn}
|
||||||
<!-- Authenticated state -->
|
<!-- Authenticated state -->
|
||||||
<div class="bg-green-50 border border-green-300 rounded-lg p-4 mb-4">
|
<div class="text-center">
|
||||||
<div class="flex items-center justify-center mb-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
class="h-6 w-6 text-green-500"
|
||||||
<span class="text-sm font-medium text-green-800">Authenticated</span>
|
fill="none"
|
||||||
</div>
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
<p class="text-sm text-green-800 mb-3">
|
stroke-width="2"
|
||||||
You are signed in.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex space-x-3 justify-center">
|
|
||||||
<button
|
|
||||||
onclick={proceed}
|
|
||||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
|
|
||||||
>
|
>
|
||||||
Continue →
|
<path
|
||||||
</button>
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Successfully Connected</h3>
|
||||||
|
</div>
|
||||||
|
<p class="mb-6 mt-2 text-sm text-gray-600">You are signed in and ready to proceed.</p>
|
||||||
<button
|
<button
|
||||||
onclick={handleSignOut}
|
onclick={handleSignOut}
|
||||||
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
|
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Unauthenticated state -->
|
<!-- Unauthenticated state -->
|
||||||
|
<div class="w-full text-center">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Ready to Connect?</h3>
|
||||||
|
<p class="mb-6 text-sm text-gray-600">
|
||||||
|
Click the button below to sign in with your Google account.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={handleSignIn}
|
onclick={handleSignIn}
|
||||||
disabled={!$isGoogleApiReady}
|
disabled={!$isGoogleApiReady}
|
||||||
class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
class="flex w-full items-center justify-center rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{#if $isGoogleApiReady}
|
{#if $isGoogleApiReady}
|
||||||
Sign In with Google
|
Sign In with Google
|
||||||
{:else}
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="mr-2 h-5 w-5 animate-spin text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
Loading Google API...
|
Loading Google API...
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div> <div class="mt-8">
|
||||||
|
<Navigator
|
||||||
|
canProceed={$isSignedIn}
|
||||||
|
{currentStep}
|
||||||
|
textBack="Back to Splash"
|
||||||
|
textForwardDisabled="Sign in to continue"
|
||||||
|
textForwardEnabled="Continue to Sheet Search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import PhotoCard from './subcomponents/PhotoCard.svelte';
|
import PhotoCard from './subcomponents/PhotoCard.svelte';
|
||||||
import * as tf from '@tensorflow/tfjs';
|
import * as tf from '@tensorflow/tfjs';
|
||||||
import * as blazeface from '@tensorflow-models/blazeface';
|
import * as blazeface from '@tensorflow-models/blazeface';
|
||||||
|
import PQueue from 'p-queue';
|
||||||
|
import { set, clear } from 'idb-keyval';
|
||||||
|
|
||||||
let photos = $state<PhotoInfo[]>([]);
|
let photos = $state<PhotoInfo[]>([]);
|
||||||
let isProcessing = $state(false);
|
let isProcessing = $state(false);
|
||||||
@@ -14,6 +16,8 @@
|
|||||||
let totalCount = $state(0);
|
let totalCount = $state(0);
|
||||||
let detector: blazeface.BlazeFaceModel | undefined;
|
let detector: blazeface.BlazeFaceModel | undefined;
|
||||||
let detectorPromise: Promise<void> | undefined;
|
let detectorPromise: Promise<void> | undefined;
|
||||||
|
let downloadQueue: PQueue;
|
||||||
|
let faceDetectionQueue: PQueue;
|
||||||
|
|
||||||
interface PhotoInfo {
|
interface PhotoInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,13 +42,59 @@
|
|||||||
return detectorPromise;
|
return detectorPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force memory cleanup
|
||||||
|
async function forceMemoryCleanup() {
|
||||||
|
await tf.nextFrame(); // Wait for any pending GPU operations
|
||||||
|
|
||||||
|
// Log memory state without aggressive cleanup
|
||||||
|
const memInfo = tf.memory();
|
||||||
|
console.log('Memory status:', {
|
||||||
|
tensors: memInfo.numTensors,
|
||||||
|
dataBuffers: memInfo.numDataBuffers,
|
||||||
|
bytes: memInfo.numBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only run garbage collection if available, don't dispose variables
|
||||||
|
if (typeof window !== 'undefined' && 'gc' in window) {
|
||||||
|
(window as any).gc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processPhotosInParallel() {
|
async function processPhotosInParallel() {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
|
||||||
console.log('Starting processPhotos in parallel...');
|
console.log('Starting processPhotos with queues...');
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
processedCount = 0;
|
processedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear previous session's images from IndexedDB
|
||||||
|
await clear();
|
||||||
|
console.log('Cleared IndexedDB.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Could not clear IndexedDB:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize queues with more conservative concurrency
|
||||||
|
downloadQueue = new PQueue({ concurrency: 3 }); // Reduced from 5
|
||||||
|
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
|
||||||
|
|
||||||
|
// When both queues are idle, we're done
|
||||||
|
downloadQueue.on('idle', async () => {
|
||||||
|
if (faceDetectionQueue.size === 0 && faceDetectionQueue.pending === 0) {
|
||||||
|
await forceMemoryCleanup(); // Clean up memory when processing is complete
|
||||||
|
isProcessing = false;
|
||||||
|
console.log('All queues are idle. Processing complete.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
faceDetectionQueue.on('idle', async () => {
|
||||||
|
if (downloadQueue.size === 0 && downloadQueue.pending === 0) {
|
||||||
|
await forceMemoryCleanup(); // Clean up memory when processing is complete
|
||||||
|
isProcessing = false;
|
||||||
|
console.log('All queues are idle. Processing complete.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const validRows = $filteredSheetData.filter((row) => row._isValid);
|
const validRows = $filteredSheetData.filter((row) => row._isValid);
|
||||||
const photoUrls = new Set<string>();
|
const photoUrls = new Set<string>();
|
||||||
const photoMap = new Map<string, any[]>();
|
const photoMap = new Map<string, any[]>();
|
||||||
@@ -62,7 +112,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
totalCount = photoUrls.size;
|
totalCount = photoUrls.size;
|
||||||
console.log(`Found ${totalCount} unique photo URLs`);
|
console.log(`Found ${totalCount} unique photo URLs to process.`);
|
||||||
|
|
||||||
photos = Array.from(photoUrls).map((url) => ({
|
photos = Array.from(photoUrls).map((url) => ({
|
||||||
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
|
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
|
||||||
@@ -72,28 +122,12 @@
|
|||||||
faceDetectionStatus: 'pending' as const
|
faceDetectionStatus: 'pending' as const
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const concurrencyLimit = 5;
|
// Add all photos to the download queue
|
||||||
const promises = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < photos.length; i++) {
|
for (let i = 0; i < photos.length; i++) {
|
||||||
const promise = (async () => {
|
downloadQueue.add(() => loadPhoto(i));
|
||||||
await loadPhoto(i);
|
|
||||||
processedCount++;
|
|
||||||
})();
|
|
||||||
promises.push(promise);
|
|
||||||
|
|
||||||
if (promises.length >= concurrencyLimit) {
|
|
||||||
await Promise.all(promises);
|
|
||||||
promises.length = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
isProcessing = false;
|
|
||||||
console.log('All photos processed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize detector and process photos
|
// Initialize detector and process photos
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
console.log('StepGallery mounted');
|
console.log('StepGallery mounted');
|
||||||
@@ -148,6 +182,8 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load photo for ${photo.name}:`, error);
|
console.error(`Failed to load photo for ${photo.name}:`, error);
|
||||||
photo.status = 'error';
|
photo.status = 'error';
|
||||||
|
// Since this step failed, we still count it as "processed" to not stall the progress bar
|
||||||
|
processedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +211,8 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
|
console.error(`Failed to convert HEIC image for ${photo.name}:`, e);
|
||||||
photo.status = 'error';
|
photo.status = 'error';
|
||||||
|
// Since this step failed, we still count it as "processed" to not stall the progress bar
|
||||||
|
processedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,12 +236,14 @@
|
|||||||
photo.status = 'success';
|
photo.status = 'success';
|
||||||
console.log(`Photo loaded successfully: ${photo.name}`);
|
console.log(`Photo loaded successfully: ${photo.name}`);
|
||||||
|
|
||||||
// Save to pictures store
|
// Save blob to IndexedDB instead of the store
|
||||||
|
await set(photo.url, blob);
|
||||||
|
|
||||||
|
// Save to pictures store, but without the blob to save memory
|
||||||
pictures.update((pics) => ({
|
pictures.update((pics) => ({
|
||||||
...pics,
|
...pics,
|
||||||
[photo.url]: {
|
[photo.url]: {
|
||||||
id: photo.url,
|
id: photo.url,
|
||||||
blob: blob,
|
|
||||||
url: objectUrl,
|
url: objectUrl,
|
||||||
downloaded: true,
|
downloaded: true,
|
||||||
faceDetected: false,
|
faceDetected: false,
|
||||||
@@ -211,32 +251,47 @@
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Automatically run face detection to generate crop
|
// Add face detection to its queue
|
||||||
await detectFaceForPhoto(index);
|
faceDetectionQueue.add(() => detectFaceForPhoto(index));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to process blob for ${photo.name}:`, error);
|
console.error(`Failed to process blob for ${photo.name}:`, error);
|
||||||
photo.status = 'error';
|
photo.status = 'error';
|
||||||
|
// Since this step failed, we still count it as "processed" to not stall the progress bar
|
||||||
|
processedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectFaceForPhoto(index: number) {
|
async function detectFaceForPhoto(index: number) {
|
||||||
|
const photo = photos[index];
|
||||||
|
let imageTensor;
|
||||||
try {
|
try {
|
||||||
await initializeDetector(); // Ensure detector is loaded
|
await initializeDetector(); // Ensure detector is loaded
|
||||||
if (!detector) {
|
if (!detector) {
|
||||||
photos[index].faceDetectionStatus = 'failed';
|
photo.faceDetectionStatus = 'failed';
|
||||||
console.error('Face detector not available.');
|
console.error('Face detector not available.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
photos[index].faceDetectionStatus = 'processing';
|
photo.faceDetectionStatus = 'processing';
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
img.src = photos[index].objectUrl!;
|
img.src = photo.objectUrl!;
|
||||||
await new Promise((r, e) => {
|
await new Promise((r, e) => {
|
||||||
img.onload = r;
|
img.onload = r;
|
||||||
img.onerror = e;
|
img.onerror = e;
|
||||||
});
|
});
|
||||||
const predictions = await detector.estimateFaces(img, false);
|
|
||||||
|
// Create tensor and manually dispose it after use
|
||||||
|
imageTensor = tf.browser.fromPixels(img);
|
||||||
|
const predictions = await detector.estimateFaces(imageTensor, false);
|
||||||
|
|
||||||
|
// Log memory usage for debugging
|
||||||
|
const memInfo = tf.memory();
|
||||||
|
console.log(`TensorFlow.js memory after face detection for ${photo.name}:`, {
|
||||||
|
numTensors: memInfo.numTensors,
|
||||||
|
numDataBuffers: memInfo.numDataBuffers,
|
||||||
|
numBytes: memInfo.numBytes
|
||||||
|
});
|
||||||
|
|
||||||
if (predictions.length > 0) {
|
if (predictions.length > 0) {
|
||||||
const getProbability = (p: number | tf.Tensor) =>
|
const getProbability = (p: number | tf.Tensor) =>
|
||||||
@@ -245,26 +300,27 @@
|
|||||||
const face = predictions.sort(
|
const face = predictions.sort(
|
||||||
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
|
(a, b) => getProbability(b.probability!) - getProbability(a.probability!)
|
||||||
)[0];
|
)[0];
|
||||||
// Coordinates in displayed image space
|
|
||||||
let [x1, y1] = face.topLeft as [number, number];
|
const topLeft = face.topLeft as [number, number];
|
||||||
let [x2, y2] = face.bottomRight as [number, number];
|
const bottomRight = face.bottomRight as [number, number];
|
||||||
// Scale to natural image size
|
|
||||||
|
let [x1, y1] = topLeft;
|
||||||
|
let [x2, y2] = bottomRight;
|
||||||
const scaleX = img.naturalWidth / img.width;
|
const scaleX = img.naturalWidth / img.width;
|
||||||
const scaleY = img.naturalHeight / img.height;
|
const scaleY = img.naturalHeight / img.height;
|
||||||
const faceWidth = (x2 - x1) * scaleX;
|
const faceWidth = (x2 - x1) * scaleX;
|
||||||
const faceHeight = (y2 - y1) * scaleY;
|
const faceHeight = (y2 - y1) * scaleY;
|
||||||
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;
|
||||||
// Load crop config from env
|
|
||||||
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
|
const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0');
|
||||||
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');
|
||||||
// Compute crop size and center
|
|
||||||
let cropWidth = faceWidth * cropScale;
|
let cropWidth = faceWidth * cropScale;
|
||||||
let cropHeight = cropWidth / cropRatio;
|
let cropHeight = cropWidth / cropRatio;
|
||||||
|
|
||||||
// If crop is larger than image, scale it down while maintaining aspect ratio
|
|
||||||
if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) {
|
if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) {
|
||||||
const widthRatio = img.naturalWidth / cropWidth;
|
const widthRatio = img.naturalWidth / cropWidth;
|
||||||
const heightRatio = img.naturalHeight / cropHeight;
|
const heightRatio = img.naturalHeight / cropHeight;
|
||||||
@@ -276,9 +332,11 @@
|
|||||||
let centerX = faceCenterX + cropWidth * offsetX;
|
let centerX = faceCenterX + cropWidth * offsetX;
|
||||||
let centerY = faceCenterY + cropHeight * offsetY;
|
let centerY = faceCenterY + cropHeight * offsetY;
|
||||||
|
|
||||||
// Clamp center to ensure crop fits
|
|
||||||
centerX = Math.max(cropWidth / 2, Math.min(centerX, img.naturalWidth - cropWidth / 2));
|
centerX = Math.max(cropWidth / 2, Math.min(centerX, img.naturalWidth - cropWidth / 2));
|
||||||
centerY = Math.max(cropHeight / 2, Math.min(centerY, img.naturalHeight - cropHeight / 2));
|
centerY = Math.max(
|
||||||
|
cropHeight / 2,
|
||||||
|
Math.min(centerY, img.naturalHeight - cropHeight / 2)
|
||||||
|
);
|
||||||
|
|
||||||
const cropX = centerX - cropWidth / 2;
|
const cropX = centerX - cropWidth / 2;
|
||||||
const cropY = centerY - cropHeight / 2;
|
const cropY = centerY - cropHeight / 2;
|
||||||
@@ -289,32 +347,40 @@
|
|||||||
width: Math.round(cropWidth),
|
width: Math.round(cropWidth),
|
||||||
height: Math.round(cropHeight)
|
height: Math.round(cropHeight)
|
||||||
};
|
};
|
||||||
photos[index].cropData = crop;
|
photo.cropData = crop;
|
||||||
photos[index].faceDetectionStatus = 'completed';
|
photo.faceDetectionStatus = 'completed';
|
||||||
|
|
||||||
// Save crop data to store
|
|
||||||
cropRects.update((crops) => ({
|
cropRects.update((crops) => ({
|
||||||
...crops,
|
...crops,
|
||||||
[photos[index].url]: crop
|
[photo.url]: crop
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Update pictures store with face detection info
|
|
||||||
pictures.update((pics) => ({
|
pictures.update((pics) => ({
|
||||||
...pics,
|
...pics,
|
||||||
[photos[index].url]: {
|
[photo.url]: {
|
||||||
...pics[photos[index].url],
|
...pics[photo.url],
|
||||||
faceDetected: true,
|
faceDetected: true,
|
||||||
faceCount: predictions.length
|
faceCount: predictions.length
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
photos[index].faceDetectionStatus = 'failed';
|
photo.faceDetectionStatus = 'failed';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Face detection failed for ${photos[index].name}:`, error);
|
console.error(`Face detection failed for ${photo.name}:`, error);
|
||||||
photos[index].faceDetectionStatus = 'failed';
|
photo.faceDetectionStatus = 'failed';
|
||||||
|
} finally {
|
||||||
|
// Manually dispose of the input tensor to prevent memory leaks
|
||||||
|
if (imageTensor) {
|
||||||
|
imageTensor.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a small delay to allow GPU memory to be freed before next operation
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// This is the final step for a photo, so we increment the processed count here.
|
||||||
|
processedCount++;
|
||||||
}
|
}
|
||||||
// No need to reassign photos array with $state reactivity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryPhoto(index: number) {
|
async function retryPhoto(index: number) {
|
||||||
@@ -325,7 +391,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
photo.retryCount++;
|
photo.retryCount++;
|
||||||
await loadPhoto(index, true);
|
// Add the retry attempt back to the download queue
|
||||||
|
downloadQueue.add(() => loadPhoto(index, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCropUpdate(
|
function handleCropUpdate(
|
||||||
@@ -364,6 +431,13 @@
|
|||||||
// Cleanup on unmount using $effect
|
// Cleanup on unmount using $effect
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
// Clear queues on component unmount to stop any ongoing processing
|
||||||
|
if (downloadQueue) {
|
||||||
|
downloadQueue.clear();
|
||||||
|
}
|
||||||
|
if (faceDetectionQueue) {
|
||||||
|
faceDetectionQueue.clear();
|
||||||
|
}
|
||||||
cleanupObjectUrls();
|
cleanupObjectUrls();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let { canProceed, photos, currentStep } = $props<{
|
|
||||||
canProceed: () => boolean;
|
|
||||||
photos: any[];
|
|
||||||
currentStep: any;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
currentStep.set(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNext() {
|
|
||||||
currentStep.set(6);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<button
|
|
||||||
onclick={handleBack}
|
|
||||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
← Back to Row Filter
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={handleNext}
|
|
||||||
disabled={!canProceed()}
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{canProceed()
|
|
||||||
? `Generate ${photos.filter(p => p.status === 'success' && p.cropData).length} Cards →`
|
|
||||||
: 'Waiting for photos to load and crop'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@@ -3,23 +3,86 @@
|
|||||||
import { filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
|
import { filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores';
|
||||||
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 { clear } from 'idb-keyval';
|
||||||
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,
|
||||||
|
getImageBlob,
|
||||||
|
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)));
|
||||||
|
|
||||||
|
// Cleanup function to clear IndexedDB and sensitive data
|
||||||
|
async function clearSensitiveData() {
|
||||||
|
try {
|
||||||
|
await clear(); // Clear all data from IndexedDB
|
||||||
|
console.log('IndexedDB cleared for security');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear IndexedDB:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab close or page unload
|
||||||
|
function handleBeforeUnload() {
|
||||||
|
clearSensitiveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Add event listener for page unload
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
|
// Start generation automatically when the component mounts
|
||||||
|
handleGenerate('people_data.pdf');
|
||||||
|
handleGenerate('people_photos.pdf');
|
||||||
|
|
||||||
|
// Cleanup function when component unmounts
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
clearSensitiveData();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Load Roboto font
|
// Load Roboto font
|
||||||
async function loadRobotoFont() {
|
async function loadRobotoFont() {
|
||||||
@@ -29,14 +92,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 +121,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 +155,127 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
fileToUpdate.state = 'done';
|
||||||
|
|
||||||
// Generate photo PDF
|
// Check if both PDFs are done, then clear sensitive data
|
||||||
progress = { stage: 'Generating photo PDF...', current: 2, total: 3 };
|
const allDone = files.every((f) => f.state === 'done' || f.state === 'error');
|
||||||
const photoPdfBytes = await generatePhotoPDF();
|
if (allDone) {
|
||||||
const photoBlob = new Blob([photoPdfBytes], { type: 'application/pdf' });
|
console.log('All PDFs generated, clearing sensitive data...');
|
||||||
const photoUrl = URL.createObjectURL(photoBlob);
|
await clearSensitiveData();
|
||||||
generatedFiles.push({
|
}
|
||||||
name: 'people_photos.pdf',
|
} catch (error: any) {
|
||||||
url: photoUrl,
|
console.error(`PDF generation failed for ${fileName}:`, error);
|
||||||
size: photoPdfBytes.length
|
fileToUpdate.state = 'error';
|
||||||
});
|
fileToUpdate.error = error.message || 'An unknown error occurred';
|
||||||
|
|
||||||
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 +286,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 +297,118 @@
|
|||||||
|
|
||||||
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}`);
|
// Get blob from IndexedDB instead of the store
|
||||||
|
const imageBlob = await getImageBlob(pictureUrl);
|
||||||
|
if (imageBlob) {
|
||||||
|
const croppedImageBytes = await cropImage(imageBlob, cropData);
|
||||||
|
const embeddedImage = await pdfDoc.embedJpg(croppedImageBytes);
|
||||||
|
|
||||||
// Crop the image
|
const imageAspectRatio = embeddedImage.width / embeddedImage.height;
|
||||||
const croppedImageBytes = await cropImage(pictureInfo.blob, cropData);
|
const photoBoxAspectRatio = photoDimsPt.width / photoDimsPt.height;
|
||||||
|
|
||||||
// Embed the cropped image in the PDF
|
|
||||||
const image = await pdfDoc.embedJpg(croppedImageBytes);
|
|
||||||
|
|
||||||
// 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
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
} catch (error) {
|
console.warn(`Image blob not found in IndexedDB for ${pictureUrl}`);
|
||||||
console.error(`Failed to embed photo for ${row.name}:`, error);
|
// Draw placeholder when blob not found
|
||||||
|
|
||||||
// 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', {
|
} catch (error: any) {
|
||||||
x: cellX + gridLayout.cellWidth / 2 - 30,
|
console.error(`Failed to embed photo for ${row.name}:`, error);
|
||||||
y: cellY + gridLayout.cellHeight / 2,
|
// Draw placeholder on error
|
||||||
size: PLACEHOLDER_CONFIG.size,
|
page.drawRectangle({
|
||||||
font: font,
|
...photoDimsPt,
|
||||||
color: rgb(PLACEHOLDER_CONFIG.color.r, PLACEHOLDER_CONFIG.color.g, PLACEHOLDER_CONFIG.color.b)
|
borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b),
|
||||||
|
borderWidth: BORDER_CONFIG.width
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} 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 +418,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 +427,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 +437,22 @@
|
|||||||
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));
|
||||||
|
|
||||||
|
// Clear sensitive data when starting over
|
||||||
|
clearSensitiveData();
|
||||||
|
|
||||||
|
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 +464,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 +473,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 +597,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
|
||||||
|
|||||||
@@ -108,24 +108,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initially select rows based on validity and "Already Printed" status
|
// Initially select rows based on validity and "Already Printed" status
|
||||||
selectedRows = new Set(
|
const rowsToConsider = processedData.filter((row) => {
|
||||||
processedData
|
|
||||||
.filter((row) => {
|
|
||||||
if (!row._isValid) return false;
|
if (!row._isValid) return false;
|
||||||
|
|
||||||
// Check "Already Printed" column value
|
|
||||||
const alreadyPrinted = row.alreadyPrinted;
|
const alreadyPrinted = row.alreadyPrinted;
|
||||||
if (alreadyPrinted) {
|
if (alreadyPrinted) {
|
||||||
const value = String(alreadyPrinted).toLowerCase().trim();
|
const value = String(alreadyPrinted).toLowerCase().trim();
|
||||||
// If the value is "true", "yes", "1", or any truthy value, don't select
|
|
||||||
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
|
return !(value === 'true' || value === 'yes' || value === '1' || value === 'x');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If empty or falsy, select the row
|
|
||||||
return true;
|
return true;
|
||||||
})
|
});
|
||||||
.map((row) => row._rowIndex)
|
|
||||||
);
|
const initialSelection = rowsToConsider.map((row) => row._rowIndex);
|
||||||
|
selectedRows = new Set(initialSelection);
|
||||||
|
|
||||||
updateSelectAllState();
|
updateSelectAllState();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,19 +139,21 @@
|
|||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
if (selectAll) {
|
if (selectAll) {
|
||||||
// Deselect all visible valid rows that aren't already printed
|
// Deselect all visible valid rows
|
||||||
filteredData.forEach((row) => {
|
filteredData.forEach((row) => {
|
||||||
if (row._isValid && !isRowAlreadyPrinted(row)) {
|
if (row._isValid) {
|
||||||
selectedRows.delete(row._rowIndex);
|
selectedRows.delete(row._rowIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Select all visible valid rows that aren't already printed
|
// Select all visible valid rows that aren't already printed
|
||||||
filteredData.forEach((row) => {
|
const rowsToSelect = filteredData.filter(
|
||||||
if (row._isValid && !isRowAlreadyPrinted(row)) {
|
(row) => row._isValid && !isRowAlreadyPrinted(row) && !selectedRows.has(row._rowIndex)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rowsToSelect) {
|
||||||
selectedRows.add(row._rowIndex);
|
selectedRows.add(row._rowIndex);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
selectedRows = new Set(selectedRows);
|
selectedRows = new Set(selectedRows);
|
||||||
updateSelectAllState();
|
updateSelectAllState();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -81,13 +81,33 @@ export function handleSignIn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleSignOut() {
|
export function handleSignOut() {
|
||||||
const token = gapi.client.getToken();
|
const savedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
if (token !== null) {
|
if (savedToken) {
|
||||||
google.accounts.oauth2.revoke(token.access_token, () => {
|
try {
|
||||||
gapi.client.setToken(null);
|
const tokenData = JSON.parse(savedToken);
|
||||||
isSignedIn.set(false);
|
if (tokenData.access_token) {
|
||||||
|
google.accounts.oauth2.revoke(tokenData.access_token, () => {
|
||||||
|
console.log('User token revoked.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing token from localStorage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disables automatic sign-in on the next page load.
|
||||||
|
google.accounts.id.disableAutoSelect();
|
||||||
|
|
||||||
|
// Clear gapi client token
|
||||||
|
gapi.client.setToken(null);
|
||||||
|
|
||||||
|
// Clear token from localStorage
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
|
||||||
|
// Update signed in state
|
||||||
|
isSignedIn.set(false);
|
||||||
|
|
||||||
|
console.log('User signed out.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSheets(query: string) {
|
export async function searchSheets(query: string) {
|
||||||
|
|||||||
@@ -1,157 +1,103 @@
|
|||||||
// 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';
|
||||||
|
import { get } from 'idb-keyval';
|
||||||
|
|
||||||
|
// 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 {
|
// Function to retrieve a blob from IndexedDB
|
||||||
x: number;
|
export async function getImageBlob(url: string): Promise<Blob | undefined> {
|
||||||
y: number;
|
return await get(url);
|
||||||
size: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhotoPosition {
|
// Calculate how many cards can fit on a page.
|
||||||
x: number;
|
export function calculateGrid(
|
||||||
y: number;
|
pageWidth: number,
|
||||||
width: number;
|
pageHeight: number,
|
||||||
height: number;
|
margin: number,
|
||||||
}
|
cardWidth: number,
|
||||||
|
cardHeight: 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
96
src/lib/pdfSettings.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user