Proper sizing in the layout
All checks were successful
Build Docker image / build (push) Successful in 3m19s
Build Docker image / deploy (push) Successful in 3s
Build Docker image / verify (push) Successful in 49s

This commit is contained in:
Roman Krček
2025-08-11 16:13:53 +02:00
parent f5c2063586
commit 44de5d9ad6
8 changed files with 299 additions and 63 deletions

View File

@@ -7,7 +7,7 @@
- Pass fucntions as props instead od dispatching events
- Mixing old (on:click) and new syntaxes for event handling is not allowed. Use only the onclick syntax
- when setting state entity, simply od variable = newValue, do not use setState or similar methods like $state.
- USe $props instead of export let!
- USe $props instead of "export let"!
- Use styling from ".github/styling.md" for any UI components.
- Refer to the ".github/core-instructions.md" for the overall structure of the application.
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
@@ -15,4 +15,5 @@
- Avoid unncessary iterations. If problems is mostly solved, stop.
- 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.
- Do not edit stores.ts ! Unless is explicitly allow you to.
- Do not edit stores.ts ! Unless is explicitly allow you to.
- Focus only on files that are relevant. Do not venture to fix other things.

View File

@@ -3,31 +3,38 @@
import Navigator from './subcomponents/Navigator.svelte';
import { onMount } from 'svelte';
let homeSection = $state('');
let esnSection = $state('');
let studiesAt = $state('');
let validityStart = $state('');
onMount(() => {
validityStart = new Date().toISOString().split('T')[0];
try {
const savedHomeSection = localStorage.getItem('homeSection');
if (savedHomeSection) {
homeSection = savedHomeSection;
const savedesnSection = localStorage.getItem('esnSection');
if (savedesnSection) {
esnSection = savedesnSection;
}
const savedStudiesAt = localStorage.getItem('studiesAt');
if (savedStudiesAt) {
studiesAt = savedStudiesAt;
}
} catch (error) {
console.error('Failed to access localStorage on mount:', error);
}
});
let canProceed = $derived(homeSection.trim() !== '' && validityStart.trim() !== '');
let canProceed = $derived(esnSection.trim() !== '' && studiesAt.trim() !== '' && validityStart.trim() !== '');
function handleContinue() {
try {
localStorage.setItem('homeSection', homeSection);
localStorage.setItem('esnSection', esnSection);
localStorage.setItem('studiesAt', studiesAt);
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
$cardDetails = { homeSection, validityStart };
// Include new field; spread in case store has more fields defined elsewhere
$cardDetails = { ...$cardDetails, esnSection, studiesAt, validityStart } as any;
}
</script>
@@ -41,18 +48,31 @@
<div class="space-y-6">
<div>
<label for="homeSection" class="mb-2 block text-sm font-medium text-gray-700">
Home Section
<label for="esnSection" class="mb-2 block text-sm font-medium text-gray-700">
ESN Section
</label>
<input
id="homeSection"
id="esnSection"
type="text"
bind:value={homeSection}
bind:value={esnSection}
placeholder="e.g., ESN VUT Brno"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="studiesAt" class="mb-2 block text-sm font-medium text-gray-700">
Studies At
</label>
<input
id="studiesAt"
type="text"
bind:value={studiesAt}
placeholder="e.g., Brno University of Technology"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="validityStart" class="mb-2 block text-sm font-medium text-gray-700">
Card Validity Start Date

View File

@@ -67,7 +67,7 @@
}
// Initialize queues with more conservative concurrency
downloadQueue = new PQueue({ concurrency: 3 }); // Reduced from 5
downloadQueue = new PQueue({ concurrency: 4 }); // Reduced from 5
faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety
// When both queues are idle, we're done

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import { sheetData, currentStep, pictures, cropRects } from '$lib/stores';
import { sheetData, currentStep, pictures, cropRects, cardDetails } from '$lib/stores';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import * as fontkit from 'fontkit';
import { clear } from 'idb-keyval';
import {
BORDER_CONFIG,
TEXT_CONFIG,
calculateGrid,
getAbsolutePositionPt,
getAbsolutePhotoDimensionsPt,
getImageBlob,
MM_TO_PT
import {
BORDER_CONFIG,
TEXT_CONFIG,
calculateGrid,
getAbsolutePositionPt,
getAbsolutePhotoDimensionsPt,
getImageBlob,
MM_TO_PT
} from '$lib/pdfLayout';
import {
PAGE_SETTINGS,
@@ -109,6 +109,138 @@
return `${year}-${month}-${day}-${hours}-${minutes}`;
}
// Draw a very wide 'H' (10 cm length) at the top and left margins as registration marks
function drawHMarks(page: any, font: any) {
const color = rgb(0, 0, 0); // pure black
const lengthPt = 100 * MM_TO_PT; // 10 cm
const tickLenPt = 2 * MM_TO_PT; // 2 mm tick
const strokePt = 0.7; // visual thickness
// Top margin center
const centerTopX = (PAGE_SETTINGS.pageWidth / 2) * MM_TO_PT;
const centerTopY = (PAGE_SETTINGS.pageHeight - PAGE_SETTINGS.margin / 2) * MM_TO_PT;
const leftTopX = centerTopX - lengthPt / 2;
const rightTopX = centerTopX + lengthPt / 2;
// Horizontal bar (top)
page.drawRectangle({
x: leftTopX,
y: centerTopY - strokePt / 2,
width: lengthPt,
height: strokePt,
color
});
// Left vertical tick (top)
page.drawRectangle({
x: leftTopX - strokePt / 2,
y: centerTopY - tickLenPt / 2,
width: strokePt,
height: tickLenPt,
color
});
// Right vertical tick (top)
page.drawRectangle({
x: rightTopX - strokePt / 2,
y: centerTopY - tickLenPt / 2,
width: strokePt,
height: tickLenPt,
color
});
// Label under the top bar, centered
const label = 'Print gauge - if not 10 cm long, the page is not printed correctly!';
const labelSize = 7;
const labelWidth = font.widthOfTextAtSize(label, labelSize);
const labelX = centerTopX - labelWidth / 2; // center horizontally
const labelY = centerTopY - 3 * MM_TO_PT; // ~3mm below the bar
page.drawText(label, {
x: labelX,
y: labelY,
size: labelSize,
font,
color
});
// Left margin center (vertical bar)
const centerLeftX = (PAGE_SETTINGS.margin / 2) * MM_TO_PT;
const centerLeftY = (PAGE_SETTINGS.pageHeight / 2) * MM_TO_PT;
// Vertical bar (left)
page.drawRectangle({
x: centerLeftX - strokePt / 2,
y: centerLeftY - lengthPt / 2,
width: strokePt,
height: lengthPt,
color
});
// Top horizontal tick (left)
page.drawRectangle({
x: centerLeftX - tickLenPt / 2,
y: centerLeftY + lengthPt / 2 - strokePt / 2,
width: tickLenPt,
height: strokePt,
color
});
// Bottom horizontal tick (left)
page.drawRectangle({
x: centerLeftX - tickLenPt / 2,
y: centerLeftY - lengthPt / 2 - strokePt / 2,
width: tickLenPt,
height: strokePt,
color
});
}
// Format a date-like string into "DD MM YY" with basic heuristics for common inputs
function formatDateDDMMYY(value: string): string {
if (!value) return '';
const trimmed = value.trim();
// ISO: YYYY-MM-DD or YYYY/MM/DD
let m = trimmed.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
if (m) {
const [, y, mo, d] = m;
return `${d} ${mo} ${y.slice(-2)}`;
}
// DMY with separators: DD.MM.YYYY or DD/MM/YYYY or DD-MM-YYYY
m = trimmed.match(/^(\d{2})[.\/-](\d{2})[.\/-](\d{4})$/);
if (m) {
const [, d, mo, y] = m;
return `${d} ${mo} ${y.slice(-2)}`;
}
// DMY two-digit year: DD.MM.YY or DD/MM/YY or DD-MM-YY
m = trimmed.match(/^(\d{2})[.\/-](\d{2})[.\/-](\d{2})$/);
if (m) {
const [, d, mo, y2] = m;
return `${d} ${mo} ${y2}`;
}
// Try native Date parsing as a last resort
const parsed = new Date(trimmed);
if (!isNaN(parsed.getTime())) {
const dd = String(parsed.getDate()).padStart(2, '0');
const mm = String(parsed.getMonth() + 1).padStart(2, '0');
const yy = String(parsed.getFullYear()).slice(-2);
return `${dd} ${mm} ${yy}`;
}
// Handle plain numeric serials (e.g., Google/Excel serial days since 1899-12-30)
if (/^\d{5,}$/.test(trimmed)) {
const days = parseInt(trimmed, 10);
const base = new Date(Date.UTC(1899, 11, 30));
base.setUTCDate(base.getUTCDate() + days);
const dd = String(base.getUTCDate()).padStart(2, '0');
const mm = String(base.getUTCMonth() + 1).padStart(2, '0');
const yy = String(base.getUTCFullYear()).slice(-2);
return `${dd} ${mm} ${yy}`;
}
return trimmed;
}
// Crop image using canvas
async function cropImage(
imageBlob: Blob,
@@ -177,7 +309,12 @@
const pdfBytes =
fileName === 'esncards_text.pdf' ? await generateTextPDF() : await generatePhotoPDF();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
// Convert Uint8Array to ArrayBuffer slice to satisfy BlobPart typing
const arrayBuffer = pdfBytes.buffer.slice(
pdfBytes.byteOffset,
pdfBytes.byteOffset + pdfBytes.byteLength
);
const blob = new Blob([arrayBuffer as ArrayBuffer], { type: 'application/pdf' });
// Revoke old URL if it exists
if (fileToUpdate.url) {
@@ -227,10 +364,15 @@
};
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
let currentRow = 0;
let currentCol = 0;
const validRows = $sheetData.filter((row) => row._valid);
const details = $cardDetails;
const studiesAtAll = details?.studiesAt ?? '';
const esnSectionAll = details?.esnSection ?? '';
const validityStartAll = details?.validityStart ?? '';
for (let i = 0; i < validRows.length; i++) {
const row = validRows[i];
@@ -244,8 +386,13 @@
const surname = row.surname;
const nationality = row.nationality;
const birthday = row.birthday;
const studiesAt = studiesAtAll;
const esnSection = esnSectionAll;
const validityStart = validityStartAll;
const birthdayFmt = formatDateDDMMYY(birthday);
const validityStartFmt = formatDateDDMMYY(validityStart);
// Draw name
// Row 1: Name
const namePos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
@@ -258,27 +405,65 @@
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Draw nationality
// Row 2 left: Nationality
const natPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.nationality
);
page.drawText(`Nationality: ${nationality}`, {
page.drawText(`${nationality}`, {
...natPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Draw birthday
// Row 2 right: Date of birth
const bdayPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.birthday
);
page.drawText(`Birthday: ${birthday}`, {
// Row 3: Studies at
const studiesPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.studiesAt
);
page.drawText(`${studiesAt}` , {
...studiesPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 4 left: ESN Section
const sectionPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.esnSection
);
page.drawText(`${esnSection}` , {
...sectionPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
// Row 4 right: Valid from
const validPos = getAbsolutePositionPt(
cellX_mm,
cellY_mm,
PAGE_SETTINGS.pageHeight,
TEXT_FIELD_LAYOUT.validityStart
);
page.drawText(`${validityStartFmt}` , {
...validPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
});
page.drawText(`${birthdayFmt}`, {
...bdayPos,
font,
color: rgb(TEXT_CONFIG.color.r, TEXT_CONFIG.color.g, TEXT_CONFIG.color.b)
@@ -301,6 +486,7 @@
currentRow++;
if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
currentRow = 0;
}
}
@@ -331,6 +517,7 @@
};
let page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
let currentRow = 0;
let currentCol = 0;
@@ -433,6 +620,7 @@
currentRow++;
if (currentRow >= gridLayout.rows) {
page = pdfDoc.addPage([pageDimsPt.width, pageDimsPt.height]);
drawHMarks(page, font);
currentRow = 0;
}
}

View File

@@ -101,20 +101,23 @@
}
}
// Function to toggle all valid rows
// Function to toggle select-all: selects first 200 eligible items in current view
function toggleSelectAll(event: Event) {
const target = event.target as HTMLInputElement;
const shouldCheck = target.checked;
rows.forEach((row) => {
if (row._valid && !row.alreadyPrinted) {
if (shouldCheck && selectedCount < ROW_LIMIT) {
row._checked = true;
} else {
row._checked = false;
}
}
});
// Determine eligible rows in the current display order
const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
const firstBatch = eligible.slice(0, ROW_LIMIT);
if (shouldCheck) {
// Check only the first batch, uncheck the rest
rows.forEach((row) => (row._checked = false));
firstBatch.forEach((row) => (row._checked = true));
} else {
// Uncheck all
rows.forEach((row) => (row._checked = false));
}
}
// Function to handle sorting
@@ -148,11 +151,12 @@
});
});
// Derived state to determine if the "Select All" checkbox should be checked
// Derived state: master checkbox reflects if first 200 eligible items in current view are selected
const allValidRowsSelected = $derived.by(() => {
const validRows = rows.filter((row) => row._valid && !row.alreadyPrinted);
if (validRows.length === 0) return false;
return validRows.every((row) => row._checked);
const eligible = displayData.filter((r) => r._valid && !r.alreadyPrinted);
const firstBatch = eligible.slice(0, ROW_LIMIT);
if (firstBatch.length === 0) return false;
return firstBatch.every((row) => row._checked);
});
const selectedCount = $derived(rows.filter((row) => row._checked).length);
@@ -169,6 +173,7 @@
<p class="text-sm text-gray-700">
Review your data and select which rows to include. Select a batch of max 200 items by using the top checkbox.
</p>
<p class="text-xs text-gray-500 mt-1">Note: Processing of cards is allowed only in batches of 200.</p>
<p class="text-sm text-gray-700">
Already printed or invalid data is marked in the status column.
</p>

View File

@@ -15,25 +15,25 @@ export interface CardDimensions {
export const PAGE_SETTINGS: PageSettings = {
pageWidth: 210,
pageHeight: 297,
margin: 10
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: 63,
height: 40
width: 45,
height: 30
};
// Dimensions for a single card in the photo PDF.
export const PHOTO_CARD_DIMENSIONS: CardDimensions = {
width: 25,
height: 35
width: 27,
height: 39
};
// Photo dimensions within the photo card
export const PHOTO_DIMENSIONS = {
width: 20, // mm
width: 25, // mm
height: 35 // mm
};
@@ -54,6 +54,9 @@ export interface TextFieldLayout {
name: TextPosition;
nationality: TextPosition;
birthday: TextPosition;
studiesAt: TextPosition;
esnSection: TextPosition;
validityStart: TextPosition;
}
export interface PhotoFieldLayout {
@@ -61,36 +64,53 @@ export interface PhotoFieldLayout {
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,
x: 3,
y: 5,
size: 10 // font size in points
size: FONT_SIZE // font size in points
},
nationality: {
x: 2,
y: 10,
size: 10
x: 3,
y: 12,
size: FONT_SIZE
},
birthday: {
x: 2,
y: 15,
size: 10
x: 30,
y: 12,
size: FONT_SIZE
},
studiesAt: {
x: 3,
y: 20,
size: FONT_SIZE
},
esnSection: {
x: 3,
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, // 2mm from left of cell
y: 2, // 2mm from top of cell
x: 2,
y: 2,
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
x: 2,
y: PHOTO_DIMENSIONS.height + 4,
size: FONT_SIZE
}
};

View File

@@ -61,7 +61,8 @@ export interface SheetInfoType {
// Card details type
export interface CardDetailsType {
homeSection: string;
esnSection: string;
studiesAt: string;
validityStart: string;
}

View File

@@ -0,0 +1 @@
export const ssr = false;