From d7a70694b9691428c93c65f6f90dcb52784a31b0 Mon Sep 17 00:00:00 2001 From: Logan Pendragon Date: Mon, 7 Sep 2026 17:21:06 -0400 Subject: [PATCH] Release 1.2.0: reviewed improvements --- CHANGELOG.md | 8 ++ DEVELOPMENT.md | 8 ++ README.md | 22 +++-- TESTING.md | 8 ++ package-lock.json | 4 +- package.json | 2 +- src/core.ts | 10 +-- src/layout.ts | 56 ++++++++++++ src/main.ts | 197 ++++++++++++++++++++++++++++++++++++----- src/queue.ts | 33 +++++++ src/render.ts | 37 ++++++-- src/spreadsheet.ts | 24 +++-- src/styles.css | 31 +++++++ src/types.ts | 1 + test/layout.test.ts | 23 +++++ test/queue.test.ts | 75 ++++++++++++++++ test/worksheet.test.ts | 21 +++++ 17 files changed, 510 insertions(+), 50 deletions(-) create mode 100644 src/layout.ts create mode 100644 src/queue.ts create mode 100644 test/layout.test.ts create mode 100644 test/queue.test.ts create mode 100644 test/worksheet.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b807664..cb4bf41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,11 @@ - Added PDF, PNG, and JPEG template previews with draggable spreadsheet fields. - Added row previews, field styling, PDF/PNG generation, collision-safe filenames, ZIP packaging, and a data-minimized manifest. - Added responsive studio UI, explicit privacy boundaries, tests, cross-platform CI, CodeQL, Pages deployment, and release packaging. + +## Version 1.2.0: reviewed improvements + +Add placement guides, keyboard/numeric positioning, duplicate fields and a cancellable selected-record ZIP queue. + +The preview toolbar wraps at 390 pixels. Fields support numeric percentages, arrow-key nudging (0.1%, Shift 1%), page-aware edge/center guides and duplication. XLSX worksheet selection and explicit record ranges control exports, with original worksheet row numbers retained in manifests and preflight. ZIP parts render sequentially, await an explicit download before continuing, and cap buffered document bytes at 32 MB per part with 1–50 records per part. Cancellation discards the current undownloaded part and stops subsequent work; previously downloaded parts remain. Inputs, parser memory, a single document render and compression overhead are outside that output-buffer cap. Large parts fail with guidance to select fewer records. Collision-safe names span the queue. Templates, rows and fonts stay local; a blank sample template supports an entirely local walkthrough. Automated PDF/ZIP tests verify numbering, cancellation and limits; desktop/mobile browser QA exercised numeric placement, duplication and queue cancellation. File-scheme launch, physical print accuracy and every XLSX producer are not claimed. + +Validation: `npm run check`, `npm audit`, and `npm run package`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bdfb1ef..111ad21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -41,3 +41,11 @@ Files are held only in page memory. The application deliberately has no persiste ## Verification Use the exact commands in [TESTING.md](TESTING.md). Claims in the README must not exceed tested behavior. Release artifacts are built from `dist/` by `scripts/package.mjs` and receive a SHA-256 sidecar. + +## Version 1.2.0: reviewed improvements + +Add placement guides, keyboard/numeric positioning, duplicate fields and a cancellable selected-record ZIP queue. + +The preview toolbar wraps at 390 pixels. Fields support numeric percentages, arrow-key nudging (0.1%, Shift 1%), page-aware edge/center guides and duplication. XLSX worksheet selection and explicit record ranges control exports, with original worksheet row numbers retained in manifests and preflight. ZIP parts render sequentially, await an explicit download before continuing, and cap buffered document bytes at 32 MB per part with 1–50 records per part. Cancellation discards the current undownloaded part and stops subsequent work; previously downloaded parts remain. Inputs, parser memory, a single document render and compression overhead are outside that output-buffer cap. Large parts fail with guidance to select fewer records. Collision-safe names span the queue. Templates, rows and fonts stay local; a blank sample template supports an entirely local walkthrough. Automated PDF/ZIP tests verify numbering, cancellation and limits; desktop/mobile browser QA exercised numeric placement, duplication and queue cancellation. File-scheme launch, physical print accuracy and every XLSX producer are not claimed. + +Validation: `npm run check`, `npm audit`, and `npm run package`. diff --git a/README.md b/README.md index 364dd1b..97aad75 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,24 @@ Batch Document Studio turns a PDF or image template and a CSV/XLSX spreadsheet into a ZIP of personalized PDF or PNG documents. It runs entirely in the browser: templates, recipient rows, rendered documents, and ZIP contents stay on the device. -Use it for certificates, award cards, event credentials, badges, name tags, or other one-page documents that repeat a visual design while changing text. +Use it for certificates, award cards, event credentials, badges, name tags, or other personalized documents that repeat a visual design while changing text. ## Three-minute path -1. Open the [browser application](https://loganpendragonmultiverse.github.io/batch-document-studio/) or download the release ZIP and open `index.html` locally. -2. Choose a one-page PDF, PNG, or JPEG template up to 25 MB. -3. Choose a CSV or XLSX spreadsheet. The first row supplies field names. +1. Open the [browser application](https://loganpendragonmultiverse.github.io/batch-document-studio/) or serve the downloaded release ZIP with a local static server. +2. Choose a multi-page PDF, PNG, or JPEG template up to 25 MB. +3. Choose a CSV or XLSX spreadsheet. Select the XLSX worksheet. The first row supplies field names. 4. Add fields, select their spreadsheet columns, choose wrapping or shrink-to-fit, and drag them onto any template page. -5. Preview multiple records, choose PDF or PNG, set a filename pattern, and select **Build ZIP**. +5. Preview multiple records, choose PDF or PNG, set a filename pattern, and select records and **Build ZIP queue**. The ZIP contains one file per non-empty spreadsheet row plus a JSON manifest connecting each filename to its original spreadsheet row number. ## What version 1 does -- Reads the first worksheet from CSV and XLSX recipient files. +- Reads CSV and explicitly selected XLSX worksheets. - Preserves duplicate or blank spreadsheet headers by assigning stable unique names. -- Previews the first page of PDF templates and full PNG/JPEG templates. +- Previews each page of PDF templates and full PNG/JPEG templates. - Maps any spreadsheet column to draggable text fields with size, color, alignment, and width controls. - Places fields independently across every page of a PDF template. - Wraps multiline text, shrinks long text to a chosen minimum size, or flags possible clipping. @@ -88,3 +88,11 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. For usage que ## License MIT. See [LICENSE](LICENSE). + +## Version 1.2.0: reviewed improvements + +Add placement guides, keyboard/numeric positioning, duplicate fields and a cancellable selected-record ZIP queue. + +The preview toolbar wraps at 390 pixels. Fields support numeric percentages, arrow-key nudging (0.1%, Shift 1%), page-aware edge/center guides and duplication. XLSX worksheet selection and explicit record ranges control exports, with original worksheet row numbers retained in manifests and preflight. ZIP parts render sequentially, await an explicit download before continuing, and cap buffered document bytes at 32 MB per part with 1–50 records per part. Cancellation discards the current undownloaded part and stops subsequent work; previously downloaded parts remain. Inputs, parser memory, a single document render and compression overhead are outside that output-buffer cap. Large parts fail with guidance to select fewer records. Collision-safe names span the queue. Templates, rows and fonts stay local; a blank sample template supports an entirely local walkthrough. Automated PDF/ZIP tests verify numbering, cancellation and limits; desktop/mobile browser QA exercised numeric placement, duplication and queue cancellation. File-scheme launch, physical print accuracy and every XLSX producer are not claimed. + +Validation: `npm run check`, `npm audit`, and `npm run package`. diff --git a/TESTING.md b/TESTING.md index 7040a72..2944bef 100644 --- a/TESTING.md +++ b/TESTING.md @@ -33,3 +33,11 @@ Manual release smoke check: Automated tests do not establish visual print accuracy, legal credential validity, font licensing, complex-script shaping, mobile precision, or compatibility with every spreadsheet producer. + +## Version 1.2.0: reviewed improvements + +Add placement guides, keyboard/numeric positioning, duplicate fields and a cancellable selected-record ZIP queue. + +The preview toolbar wraps at 390 pixels. Fields support numeric percentages, arrow-key nudging (0.1%, Shift 1%), page-aware edge/center guides and duplication. XLSX worksheet selection and explicit record ranges control exports, with original worksheet row numbers retained in manifests and preflight. ZIP parts render sequentially, await an explicit download before continuing, and cap buffered document bytes at 32 MB per part with 1–50 records per part. Cancellation discards the current undownloaded part and stops subsequent work; previously downloaded parts remain. Inputs, parser memory, a single document render and compression overhead are outside that output-buffer cap. Large parts fail with guidance to select fewer records. Collision-safe names span the queue. Templates, rows and fonts stay local; a blank sample template supports an entirely local walkthrough. Automated PDF/ZIP tests verify numbering, cancellation and limits; desktop/mobile browser QA exercised numeric placement, duplication and queue cancellation. File-scheme launch, physical print accuracy and every XLSX producer are not claimed. + +Validation: `npm run check`, `npm audit`, and `npm run package`. diff --git a/package-lock.json b/package-lock.json index b238a09..5229297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "batch-document-studio", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "batch-document-studio", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@pdf-lib/fontkit": "^1.1.1", diff --git a/package.json b/package.json index 6b358d3..e6ed81c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "batch-document-studio", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/src/core.ts b/src/core.ts index 7cb92d1..8dac416 100644 --- a/src/core.ts +++ b/src/core.ts @@ -132,14 +132,14 @@ export function preflightBatch(options: { findings.push({ severity: "error", code: "invalid-page", - rowNumber: rowIndex + 2, + rowNumber: options.dataset.rowNumbers?.[rowIndex] ?? rowIndex + 2, fieldId: field.id, }); if (!value.trim()) findings.push({ severity: "warning", code: "missing-value", - rowNumber: rowIndex + 2, + rowNumber: options.dataset.rowNumbers?.[rowIndex] ?? rowIndex + 2, fieldId: field.id, }); const availableWidth = page ? field.width * page.width : 0; @@ -168,7 +168,7 @@ export function preflightBatch(options: { findings.push({ severity: "warning", code: "text-overflow", - rowNumber: rowIndex + 2, + rowNumber: options.dataset.rowNumbers?.[rowIndex] ?? rowIndex + 2, fieldId: field.id, }); } @@ -176,14 +176,14 @@ export function preflightBatch(options: { findings.push({ severity: "warning", code: "png-extra-page", - rowNumber: rowIndex + 2, + rowNumber: options.dataset.rowNumbers?.[rowIndex] ?? rowIndex + 2, fieldId: field.id, }); if (field.fontFamily === "custom" && !options.hasCustomFont) findings.push({ severity: "error", code: "custom-font-missing", - rowNumber: rowIndex + 2, + rowNumber: options.dataset.rowNumbers?.[rowIndex] ?? rowIndex + 2, fieldId: field.id, }); } diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..cb88822 --- /dev/null +++ b/src/layout.ts @@ -0,0 +1,56 @@ +import { clamp } from "./core"; +import type { TextField } from "./types"; +export function placeField(field: TextField, x: number, y: number): TextField { + if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Position must be finite."); + return { ...field, x: clamp(x, 0, 1 - field.width), y: clamp(y, 0, 1 - field.height) }; +} +export function alignmentGuides( + field: TextField, + others: TextField[], +): { x: number[]; y: number[] } { + const result = { x: [] as number[], y: [] as number[] }; + for (const axis of ["x", "y"] as const) { + const size = axis === "x" ? "width" : "height"; + const anchors = [ + 0, + 0.5, + 1, + ...others + .filter((f) => f.id !== field.id && f.pageIndex === field.pageIndex) + .flatMap((f) => [f[axis], f[axis] + f[size] / 2, f[axis] + f[size]]), + ]; + result[axis] = [ + ...new Set( + anchors.filter((a) => + [field[axis], field[axis] + field[size] / 2, field[axis] + field[size]].some( + (b) => Math.abs(a - b) < 0.006, + ), + ), + ), + ]; + } + return result; +} +export function selectedDataset< + T extends { rows: Record[]; rowNumbers?: number[] }, +>(dataset: T, selection: string): T & { rowNumbers: number[] } { + if (!selection.trim()) throw new Error("Select record numbers, for example 1-3,5, or all."); + const indices = new Set(); + if (selection.trim().toLowerCase() === "all") dataset.rows.forEach((_, i) => indices.add(i)); + else + for (const part of selection.split(",")) { + const match = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(part); + if (!match) throw new Error("Use record numbers such as 1-3,5."); + const first = Number(match[1]), + last = Number(match[2] ?? match[1]); + if (first < 1 || last < first || last > dataset.rows.length) + throw new Error("Record selection is outside this worksheet."); + for (let n = first; n <= last; n++) indices.add(n - 1); + } + const order = [...indices].sort((a, b) => a - b); + return { + ...dataset, + rows: order.map((i) => dataset.rows[i]!), + rowNumbers: order.map((i) => dataset.rowNumbers?.[i] ?? i + 2), + }; +} diff --git a/src/main.ts b/src/main.ts index 49557eb..33593cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,10 @@ import "./styles.css"; import { clamp, createRecipe, preflightBatch, validateFields, validateRecipe } from "./core"; -import { downloadBlob, generateArchive } from "./render"; -import { readSpreadsheet } from "./spreadsheet"; +import { downloadBlob } from "./render"; +import { generateQueue } from "./queue"; +import { placeField, alignmentGuides, selectedDataset } from "./layout"; +import { readSpreadsheet, worksheetNames } from "./spreadsheet"; import { readTemplate } from "./template"; import type { Dataset, @@ -35,8 +37,8 @@ app.innerHTML = `
01

Template

-

PDF, PNG, or JPEG · first page

- +

PDF, PNG, or JPEG · multi-page PDF

+
@@ -46,6 +48,7 @@ app.innerHTML = `

Recipient data

CSV or XLSX

+ @@ -104,6 +107,9 @@ app.innerHTML = ` +
+

Focus a field and use arrow keys to nudge 0.1%; Shift moves 1%. Guides show aligned edges and centers.

+ @@ -124,7 +130,11 @@ app.innerHTML = ` - +

Numbers refer to non-empty records in this worksheet. Proof files retain original spreadsheet row numbers.

+

One ZIP at a time; 32 MB maximum buffered document bytes per ZIP. Download each part to continue. Inputs stay in page memory.

+ + +
Load a template and spreadsheet to begin.
@@ -138,6 +148,8 @@ let selectedFieldId: string | null = null; let previewIndex = 0; let pageIndex = 0; let customFont: FontAsset | undefined; +let queueController: AbortController | null = null; +let spreadsheetFile: File | null = null; const element = (selector: string): T => { const found = document.querySelector(selector); @@ -170,8 +182,8 @@ function updateReadiness(): void { element("#template-card").dataset.ready = String(Boolean(template)); element("#data-card").dataset.ready = String(Boolean(dataset)); element("#fields-card").dataset.ready = String(fields.length > 0); - addFieldButton.disabled = !(template && dataset); - generateButton.disabled = !(template && dataset && fields.length > 0); + addFieldButton.disabled = Boolean(queueController) || !(template && dataset); + generateButton.disabled = Boolean(queueController) || !(template && dataset && fields.length > 0); preflightButton.disabled = generateButton.disabled; element("#fields-summary").textContent = fields.length ? `${fields.length} field${fields.length === 1 ? "" : "s"} on the proof` @@ -235,10 +247,37 @@ function renderFields(): void { node.style.color = field.color; node.style.textAlign = field.alignment; node.style.whiteSpace = field.fit === "wrap" ? "normal" : "nowrap"; + node.addEventListener("keydown", (event) => { + const delta = event.shiftKey ? 0.01 : 0.001; + const keys: Record = { + ArrowLeft: [-delta, 0], + ArrowRight: [delta, 0], + ArrowUp: [0, -delta], + ArrowDown: [0, delta], + }; + const change = keys[event.key]; + if (!change) return; + event.preventDefault(); + Object.assign(field, placeField(field, field.x + change[0], field.y + change[1])); + selectedFieldId = field.id; + renderFields(); + fieldLayer.querySelector(`[data-field-id="${field.id}"]`)?.focus(); + }); node.addEventListener("click", () => selectField(field.id)); node.addEventListener("pointerdown", (event) => beginDrag(event, field.id)); fieldLayer.append(node); } + const selected = selectedField(); + if (selected && selected.pageIndex === pageIndex) { + const guides = alignmentGuides(selected, fields); + for (const axis of ["x", "y"] as const) + for (const position of guides[axis]) { + const guide = document.createElement("div"); + guide.className = `alignment-guide ${axis}`; + guide.style[axis === "x" ? "left" : "top"] = `${position * 100}%`; + fieldLayer.append(guide); + } + } updateInspector(); updateReadiness(); } @@ -255,7 +294,7 @@ function beginDrag(event: PointerEvent, fieldId: string): void { const originalY = field.y; const move = (next: PointerEvent): void => { field.x = clamp(originalX + (next.clientX - startX) / bounds.width, 0, 1 - field.width); - field.y = clamp(originalY + (next.clientY - startY) / bounds.height, 0, 0.96); + field.y = clamp(originalY + (next.clientY - startY) / bounds.height, 0, 1 - field.height); renderFields(); }; const end = (): void => { @@ -291,6 +330,8 @@ function updateInspector(): void { }), ); column.value = field.column; + element("#field-x").value = String(Math.round(field.x * 1000) / 10); + element("#field-y").value = String(Math.round(field.y * 1000) / 10); element("#field-size").value = String(field.fontSize); element("#field-color").value = field.color; element("#field-align").value = field.alignment; @@ -309,7 +350,7 @@ function addField(): void { id: crypto.randomUUID(), column: header, x: 0.2, - y: 0.35 + fields.length * 0.08, + y: Math.min(0.85, 0.35 + fields.length * 0.08), width: 0.6, height: 0.15, fontSize: 32, @@ -354,6 +395,7 @@ async function handleTemplate(file: File): Promise { async function handleDataset(nextDataset: Dataset): Promise { dataset = nextDataset; + element("#row-selection").value = "all"; previewIndex = 0; element("#data-summary").textContent = `${dataset.sourceName} · ${dataset.rows.length} records`; @@ -362,6 +404,22 @@ async function handleDataset(nextDataset: Dataset): Promise { setProgress("Data ready. Add fields, position them, and build the ZIP."); } +element("#blank-template").addEventListener("click", () => { + const canvas = document.createElement("canvas"); + canvas.width = 900; + canvas.height = 600; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#fffaf0"; + ctx.fillRect(0, 0, 900, 600); + ctx.strokeStyle = "#8a7650"; + ctx.lineWidth = 3; + ctx.strokeRect(30, 30, 840, 540); + canvas.toBlob((blob) => { + if (blob) void handleTemplate(new File([blob], "sample-template.png", { type: "image/png" })); + }, "image/png"); +}); + templateInput.addEventListener("change", () => { const file = templateInput.files?.[0]; if (file) void handleTemplate(file); @@ -370,7 +428,16 @@ templateInput.addEventListener("change", () => { dataInput.addEventListener("change", () => { const file = dataInput.files?.[0]; if (!file) return; - void readSpreadsheet(file) + spreadsheetFile = file; + void worksheetNames(file) + .then(async (names) => { + const select = element("#worksheet"); + select.replaceChildren( + ...(names.length ? names : ["CSV"]).map((name) => new Option(name, name)), + ); + select.disabled = names.length < 2; + return readSpreadsheet(file, names[0] ?? 1); + }) .then(handleDataset) .catch((error: unknown) => setProgress(error instanceof Error ? error.message : "Data loading failed.", true), @@ -417,6 +484,33 @@ pageSelect.addEventListener("change", () => { }); addFieldButton.addEventListener("click", addField); +element("#worksheet").addEventListener("change", (event) => { + if (spreadsheetFile) + void readSpreadsheet(spreadsheetFile, (event.target as HTMLSelectElement).value) + .then(handleDataset) + .catch((error) => setProgress(String(error), true)); +}); +for (const axis of ["x", "y"] as const) + element(`#field-${axis}`).addEventListener("input", (event) => { + const field = selectedField(); + if (!field) return; + const value = Number((event.target as HTMLInputElement).value) / 100; + if (Number.isFinite(value)) { + Object.assign( + field, + placeField(field, axis === "x" ? value : field.x, axis === "y" ? value : field.y), + ); + renderFields(); + } + }); +element("#duplicate-field").addEventListener("click", () => { + const field = selectedField(); + if (!field) return; + const copy = placeField({ ...field, id: crypto.randomUUID() }, field.x + 0.02, field.y + 0.02); + fields.push(copy); + selectedFieldId = copy.id; + renderFields(); +}); element("#field-column").addEventListener("change", (event) => { const field = selectedField(); @@ -508,7 +602,7 @@ function outputFormat(): OutputFormat { function currentPreflight() { if (!template || !dataset) throw new Error("Load a template and spreadsheet first."); return preflightBatch({ - dataset, + dataset: selectedDataset(dataset, element("#row-selection").value), fields, template, format: outputFormat(), @@ -592,6 +686,13 @@ generateButton.addEventListener("click", () => { return; } const format = outputFormat(); + let exportData: Dataset; + try { + exportData = selectedDataset(dataset, element("#row-selection").value); + } catch (error) { + setProgress(String(error), true); + return; + } const preflight = currentPreflight(); const preflightErrors = preflight.findings.filter((finding) => finding.severity === "error"); if (preflightErrors.length) { @@ -604,29 +705,81 @@ generateButton.addEventListener("click", () => { const filenamePattern = element("#filename-pattern").value.trim() || "document-{first_name}-{last_name}"; - const recordCount = dataset.rows.length; + const recordCount = exportData.rows.length; generateButton.disabled = true; setProgress(`Building ${recordCount} ${format.toUpperCase()} files…`); - void generateArchive({ + queueController = new AbortController(); + const signal = queueController.signal; + const cancel = element("#cancel-queue"); + cancel.hidden = false; + // Freeze controls while retaining the cancel/download controls. + const controls = [ + ...document.querySelectorAll( + "input,button,select", + ), + ].filter((c) => c.id !== "cancel-queue" && c.id !== "download-part"); + const disabled = controls.map((c) => c.disabled); + controls.forEach((c) => (c.disabled = true)); + void generateQueue({ template, - dataset, - fields, + dataset: exportData, + fields: structuredClone(fields), format, filenamePattern, customFont, + signal, + batchSize: Number(element("#batch-size").value), onProgress: (completed, total) => setProgress(`Rendered ${completed} of ${total} documents…`), + onArchive: (archive, part) => + new Promise((resolve, reject) => { + const button = element("#download-part"); + button.hidden = false; + button.textContent = `Download ZIP part ${part} and continue`; + const cleanup = () => { + button.hidden = true; + button.onclick = null; + signal.removeEventListener("abort", abort); + }; + const abort = () => { + cleanup(); + reject(new Error("Queue cancelled. Previously downloaded parts remain available.")); + }; + signal.addEventListener("abort", abort, { once: true }); + button.onclick = () => { + downloadBlob(archive, `batch-document-studio-${format}-part-${part}.zip`); + cleanup(); + resolve(); + }; + setProgress(`ZIP part ${part} ready. Download it to continue the queue.`); + }), }) - .then((archive) => { - downloadBlob(archive, `batch-document-studio-${format}.zip`); - setProgress(`Finished ${recordCount} documents. The ZIP download is ready.`); - }) + .then((parts) => setProgress(`Finished ${recordCount} documents in ${parts} ZIP parts.`)) .catch((error: unknown) => - setProgress(error instanceof Error ? error.message : "Batch generation failed.", true), + setProgress( + signal.aborted + ? "Queue cancelled. Previously downloaded parts are retained." + : error instanceof Error + ? error.message + : "Generation failed.", + true, + ), ) - .finally(() => updateReadiness()); + .finally(() => { + queueController = null; + cancel.hidden = true; + controls.forEach((c, i) => (c.disabled = disabled[i] ?? false)); + updateReadiness(); + }); }); +element("#cancel-queue").addEventListener("click", () => + queueController?.abort(), +); + element("#clear-session").addEventListener("click", () => { + spreadsheetFile = null; + element("#worksheet").replaceChildren(new Option("CSV / choose XLSX")); + element("#worksheet").disabled = true; template = null; dataset = null; fields = []; @@ -642,7 +795,7 @@ element("#clear-session").addEventListener("click", () => { element("#recipe-input").value = ""; canvasWrap.hidden = true; element("#empty-stage").hidden = false; - element("#template-summary").textContent = "PDF, PNG, or JPEG · first page"; + element("#template-summary").textContent = "PDF, PNG, or JPEG · multi-page PDF"; element("#data-summary").textContent = "CSV or XLSX"; element("#record-label").textContent = "Waiting for a template and data"; renderRecordPicker(); diff --git a/src/queue.ts b/src/queue.ts new file mode 100644 index 0000000..f7dd35c --- /dev/null +++ b/src/queue.ts @@ -0,0 +1,33 @@ +import { planOutputNames } from "./core"; +import { generateArchive } from "./render"; +export async function generateQueue( + options: Parameters[0] & { + batchSize: number; + onArchive: (blob: Blob, part: number) => Promise; + }, +): Promise { + if (!Number.isInteger(options.batchSize) || options.batchSize < 1 || options.batchSize > 50) + throw new Error("Choose 1–50 records per ZIP."); + const names = planOutputNames(options.dataset.rows, options.filenamePattern, options.format); + let completed = 0; + for (let start = 0; start < options.dataset.rows.length; start += options.batchSize) { + options.signal?.throwIfAborted(); + const rows = options.dataset.rows.slice(start, start + options.batchSize); + const dataset = { + ...options.dataset, + rows, + rowNumbers: rows.map((_, i) => options.dataset.rowNumbers?.[start + i] ?? start + i + 2), + }; + const archive = await generateArchive({ + ...options, + dataset, + plannedNames: names + .slice(start, start + options.batchSize) + .map((name, i) => ({ ...name, rowIndex: i })), + onProgress: (n) => options.onProgress?.(start + n, options.dataset.rows.length), + }); + options.signal?.throwIfAborted(); + await options.onArchive(archive, ++completed); + } + return completed; +} diff --git a/src/render.ts b/src/render.ts index 4711c2b..cb12219 100644 --- a/src/render.ts +++ b/src/render.ts @@ -169,10 +169,15 @@ export async function generateArchive(options: { filenamePattern: string; onProgress?: (completed: number, total: number) => void; customFont?: FontAsset; + signal?: AbortSignal; + maxBytes?: number; + plannedNames?: ReturnType; }): Promise { const { template, dataset, fields, format, filenamePattern, onProgress, customFont } = options; + options.signal?.throwIfAborted(); + let bufferedBytes = 0; const zip = new JSZip(); - const names = planOutputNames(dataset.rows, filenamePattern, format); + const names = options.plannedNames ?? planOutputNames(dataset.rows, filenamePattern, format); const preflight = preflightBatch({ dataset, fields, @@ -181,12 +186,20 @@ export async function generateArchive(options: { hasCustomFont: Boolean(customFont), }); for (const [index, row] of dataset.rows.entries()) { + await new Promise((resolve) => setTimeout(resolve, 0)); + options.signal?.throwIfAborted(); const name = names[index]; if (!name) continue; const output = format === "pdf" ? await renderPdf(template, row, fields, customFont) : await renderPng(template, row, fields, customFont); + options.signal?.throwIfAborted(); + bufferedBytes += output instanceof Uint8Array ? output.byteLength : output.size; + if (bufferedBytes > (options.maxBytes ?? 32 * 1024 * 1024)) + throw new Error( + "This ZIP exceeds the 32 MB output buffer limit. Choose fewer records per ZIP.", + ); zip.file(name.fileName, output); onProgress?.(index + 1, dataset.rows.length); } @@ -203,7 +216,10 @@ export async function generateArchive(options: { pageCount: template.pageSizes.length, customFont: customFont?.name ?? null, preflight: preflight.counts, - files: names.map(({ rowIndex, fileName }) => ({ rowNumber: rowIndex + 2, fileName })), + files: names.map(({ rowIndex, fileName }) => ({ + rowNumber: dataset.rowNumbers?.[rowIndex] ?? rowIndex + 2, + fileName, + })), }, null, 2, @@ -211,7 +227,7 @@ export async function generateArchive(options: { ); zip.file("batch-document-studio-preflight.json", JSON.stringify(preflight, null, 2)); const proofRows = dataset.rows.map((_row, index) => { - const rowNumber = index + 2; + const rowNumber = dataset.rowNumbers?.[index] ?? index + 2; const counts = preflight.findings .filter((finding) => finding.rowNumber === rowNumber) .reduce>((result, finding) => { @@ -228,11 +244,16 @@ export async function generateArchive(options: { "batch-document-studio-proof.csv", `row_number,finding_count,summary\n${proofRows.join("\n")}\n`, ); - return zip.generateAsync({ - type: "blob", - compression: "DEFLATE", - compressionOptions: { level: 6 }, - }); + const archive = await zip.generateAsync( + { + type: "blob", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }, + () => options.signal?.throwIfAborted(), + ); + options.signal?.throwIfAborted(); + return archive; } export function downloadBlob(blob: Blob, name: string): void { diff --git a/src/spreadsheet.ts b/src/spreadsheet.ts index a4d2c47..dca543f 100644 --- a/src/spreadsheet.ts +++ b/src/spreadsheet.ts @@ -1,4 +1,4 @@ -import { readSheet } from "read-excel-file/browser"; +import readSheets, { readSheet } from "read-excel-file/browser"; import { normalizeHeaders } from "./core"; import type { Dataset } from "./types"; @@ -16,7 +16,14 @@ export function parseRows(matrix: unknown[][], sourceName: string): Dataset { ); const nonEmptyRows = rows.filter((row) => Object.values(row).some((value) => value !== "")); if (nonEmptyRows.length === 0) throw new Error("The spreadsheet has headers but no data rows."); - return { headers, rows: nonEmptyRows, sourceName }; + return { + headers, + rows: nonEmptyRows, + sourceName, + rowNumbers: rows.flatMap((row, i) => + Object.values(row).some((value) => value !== "") ? [i + 2] : [], + ), + }; } export function parseCsv(text: string): unknown[][] { @@ -40,7 +47,7 @@ export function parseCsv(text: string): unknown[][] { } else if ((character === "\n" || character === "\r") && !quoted) { if (character === "\r" && next === "\n") index += 1; row.push(value); - if (row.some((cell) => cell !== "")) rows.push(row); + rows.push(row); row = []; value = ""; } else { @@ -53,9 +60,16 @@ export function parseCsv(text: string): unknown[][] { return rows; } -export async function readSpreadsheet(file: File): Promise { +export async function readSpreadsheet(file: File, sheet: string | number = 1): Promise { const extension = file.name.split(".").pop()?.toLowerCase(); if (extension === "csv") return parseRows(parseCsv(await file.text()), file.name); - if (extension === "xlsx") return parseRows((await readSheet(file)) as unknown[][], file.name); + if (extension === "xlsx") + return parseRows((await readSheet(file, sheet)) as unknown[][], file.name); throw new Error("Choose a CSV or XLSX spreadsheet."); } + +export async function worksheetNames(file: File): Promise { + if (file.size > 25 * 1024 * 1024) throw new Error("Spreadsheet exceeds 25 MB."); + if (!file.name.toLowerCase().endsWith(".xlsx")) return []; + return (await readSheets(file)).map((sheet) => sheet.sheet); +} diff --git a/src/styles.css b/src/styles.css index 92ded9b..3908d44 100644 --- a/src/styles.css +++ b/src/styles.css @@ -560,3 +560,34 @@ label.file-button:has(input:focus-visible) { border-top: 1px solid var(--line); } } + +.studio-toolbar { + flex-wrap: wrap; + gap: 12px; +} +.studio-toolbar > div { + flex: 1 1 160px; + min-width: 0; +} +.record-picker { + min-width: 0; + max-width: 100%; +} +.record-picker select { + max-width: 100%; +} +.alignment-guide { + position: absolute; + pointer-events: none; + z-index: 5; +} +.alignment-guide.x { + top: 0; + bottom: 0; + border-left: 1px dashed #bd365d; +} +.alignment-guide.y { + left: 0; + right: 0; + border-top: 1px dashed #bd365d; +} diff --git a/src/types.ts b/src/types.ts index 667d81a..6d29bde 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export interface Dataset { headers: string[]; rows: Record[]; sourceName: string; + rowNumbers?: number[]; } export interface TextField { diff --git a/test/layout.test.ts b/test/layout.test.ts new file mode 100644 index 0000000..c5782c6 --- /dev/null +++ b/test/layout.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "vitest"; +import { alignmentGuides, placeField, selectedDataset } from "../src/layout"; +import type { TextField } from "../src/types"; +it("clamps nudges and computes page-specific edge/center guides", () => { + const field = { id: "one", x: 0.2, y: 0.2, width: 0.6, height: 0.1, pageIndex: 0 } as TextField; + expect(placeField(field, 2, -1)).toMatchObject({ x: 0.4, y: 0 }); + expect(field.x).toBe(0.2); + expect(() => placeField(field, NaN, 0)).toThrow("finite"); + expect(alignmentGuides(field, []).x).toContain(0.5); + expect(alignmentGuides(field, [{ ...field, id: "two", x: 0.2, pageIndex: 1 }]).x).not.toContain( + 0.2, + ); + expect(alignmentGuides(field, [{ ...field, id: "two", x: 0.2 }]).x).toContain(0.2); +}); +it("selects unique record ranges while preserving original worksheet row numbers", () => { + const data = { rows: [{ name: "a" }, { name: "b" }, { name: "c" }], rowNumbers: [2, 4, 8] }; + expect(selectedDataset(data, "3,1-2,2").rowNumbers).toEqual([2, 4, 8]); + expect(selectedDataset(data, "2").rows).toEqual([{ name: "b" }]); + expect(selectedDataset(data, "all")).toEqual(data); + for (const bad of ["", "0", "4", "3-1", "1,x"]) + expect(() => selectedDataset(data, bad)).toThrow(); + expect(selectedDataset({ rows: data.rows }, "2").rowNumbers).toEqual([3]); +}); diff --git a/test/queue.test.ts b/test/queue.test.ts new file mode 100644 index 0000000..efcc66d --- /dev/null +++ b/test/queue.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from "vitest"; +import JSZip from "jszip"; +import { PDFDocument } from "pdf-lib"; +import { generateArchive } from "../src/render"; +import { generateQueue } from "../src/queue"; +import type { TemplateDocument } from "../src/types"; +async function fixture() { + const pdf = await PDFDocument.create(); + pdf.addPage([100, 100]); + return { + template: { + bytes: await pdf.save(), + name: "blank.pdf", + mimeType: "application/pdf", + width: 100, + height: 100, + pageSizes: [{ width: 100, height: 100 }], + previews: [], + preview: {} as HTMLCanvasElement, + } as TemplateDocument, + dataset: { + sourceName: "local.csv", + headers: ["name"], + rows: [{ name: "A" }, { name: "B" }, { name: "C" }], + rowNumbers: [2, 4, 8], + }, + fields: [], + format: "pdf" as const, + filenamePattern: "{name}", + }; +} +it("awaits each consumed part and retains worksheet evidence", async () => { + const options = await fixture(); + const parts: number[] = []; + const total = await generateQueue({ + ...options, + batchSize: 1, + onArchive: async (blob, part) => { + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const manifest = JSON.parse( + await zip.file("batch-document-studio-manifest.json")!.async("string"), + ); + expect(manifest.files[0].rowNumber).toBe(options.dataset.rowNumbers[part - 1]); + parts.push(part); + }, + }); + expect(total).toBe(3); + expect(parts).toEqual([1, 2, 3]); +}); +it("cancels after a part without rendering subsequent parts", async () => { + const controller = new AbortController(); + let parts = 0; + await expect( + generateQueue({ + ...(await fixture()), + signal: controller.signal, + batchSize: 1, + onArchive: async () => { + parts++; + controller.abort(); + }, + }), + ).rejects.toThrow(); + expect(parts).toBe(1); +}); +it("rejects buffer overflow and invalid queue sizes", async () => { + const options = await fixture(); + await expect(generateArchive({ ...options, maxBytes: 1 })).rejects.toThrow("buffer limit"); + await expect( + generateQueue({ ...options, batchSize: 0, onArchive: async () => {} }), + ).rejects.toThrow("1–50"); + const c = new AbortController(); + c.abort(); + await expect(generateArchive({ ...options, signal: c.signal })).rejects.toThrow(); +}); diff --git a/test/worksheet.test.ts b/test/worksheet.test.ts new file mode 100644 index 0000000..29ac44b --- /dev/null +++ b/test/worksheet.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from "vitest"; +vi.mock("read-excel-file/browser", () => ({ + default: vi.fn(async () => [ + { sheet: "People", data: [] }, + { sheet: "Awards", data: [] }, + ]), + readSheet: vi.fn(async (_file, sheet) => [["name"], [sheet]]), +})); +import { readSpreadsheet, worksheetNames, parseRows } from "../src/spreadsheet"; +it("lists worksheets and passes the explicit choice into parsing", async () => { + const file = new File(["fixture"], "book.xlsx"); + expect(await worksheetNames(file)).toEqual(["People", "Awards"]); + expect((await readSpreadsheet(file, "Awards")).rows).toEqual([{ name: "Awards" }]); + expect((await readSpreadsheet(new File(["name\nAda"], "rows.csv"))).rows).toEqual([ + { name: "Ada" }, + ]); + expect(await worksheetNames(new File([], "rows.csv"))).toEqual([]); + await expect(readSpreadsheet(new File([], "bad.txt"))).rejects.toThrow("CSV or XLSX"); + await expect(worksheetNames({ size: 26 * 1024 * 1024 } as File)).rejects.toThrow("25 MB"); + expect(parseRows([["name"], ["A"], [""], ["B"]], "book").rowNumbers).toEqual([2, 4]); +});