feat(clock): use still photos with required document scan review
This commit is contained in:
parent
2c97f36ff4
commit
87f966fb89
17 changed files with 701 additions and 505 deletions
67
docs/superpowers/plans/2026-11-06-clock-scan-quality.md
Normal file
67
docs/superpowers/plans/2026-11-06-clock-scan-quality.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Clock document scan quality implementation plan
|
||||
|
||||
> Execute inline with superpowers:executing-plans. The user approved the capture proposal on 6 November 2026; proceed without another approval round.
|
||||
|
||||
**Goal:** Require boundary confirmation and readable review for every photo, preserve originals, and prevent poor capture quality from silently passing as readable.
|
||||
|
||||
**Architecture:** Extend the existing client scanner and crop editor. Package visible scan pages and embedded original-photo attachments in one deterministic PDF, using existing upload/dedup endpoints and source storage. Persist poor-quality override metadata in PDF subject and turn it into a human review question during staged ingestion.
|
||||
|
||||
**Tech:** React 19, current Next client/route guidance, canvas, pdf-lib, existing quality/crop helpers, Vitest/Testing Library.
|
||||
|
||||
**Spec:** Approved proposal in this chat; investigation at foreman/runs/dn-inbox-review/2026-11-06-06-16-scanner-investigation.md.
|
||||
|
||||
## Constraints and decisions
|
||||
|
||||
- No inbox matching remediation, live business-data mutations, deployment, schema changes, or new dependencies.
|
||||
- Retain the current product visual language and large phone controls. Every photo needs crop confirmation and Use scan; poor scans additionally need an explicit reason.
|
||||
- Native still-photo input only. At 06:55 SAST the user explicitly removed the live video preview and video-stream capture from delivery-note and PO scanning; video stays in QR scanners. Never represent an undetected whole-scene sharpness score as a good document.
|
||||
- Conservative grayscale illumination correction is optional and reversible. No reconstructed digits, hard thresholding, or generated enhancement.
|
||||
- Gallery images use the same capture review; PDFs retain the existing document-upload path. Do not mix PDFs and photos in one selection.
|
||||
- Original attachments are not additional rendered pages and must not become separate inbox documents. Identical reviewed content produces identical PDF bytes for retry dedup.
|
||||
- Work in the current folder on codex/clock-scan-quality, preserving existing unrelated edits. User authorization to proceed covers routine branching; do not relocate their active workspace.
|
||||
|
||||
## Review focus
|
||||
|
||||
- Detection fails on a cluttered desk: require boundaries, no green quality reassurance.
|
||||
- Earlier bad/unconfirmed pages in a bundle cannot be bypassed by Send from camera.
|
||||
- Still-photo input opens the native camera; delivery/PO screens do not call getUserMedia or mount a video element. QR scanning remains unchanged.
|
||||
- Crop/enhancement/retake changes invalidate page acceptance and release obsolete URLs without revoking original evidence.
|
||||
- Upload retry and duplicate detection preserve complete, deterministic packages and quality review decisions.
|
||||
|
||||
## Task 1: Document quality and enhancement
|
||||
|
||||
Files: src/lib/document-scan-quality.ts(.test.ts), src/lib/document-photo.ts.
|
||||
|
||||
- [x] Add failing tests for missing boundaries, small/far pages, insufficient pixels, blank/faint body and preserving faint grayscale detail.
|
||||
- [x] Implement document geometry/body quality assessment and reversible local illumination correction. Existing image-quality API remains compatible.
|
||||
- [x] Run focused quality/crop tests.
|
||||
|
||||
## Task 2: Evidence package and ingestion review
|
||||
|
||||
Files: src/lib/scan-pdf.ts(.test.ts), src/lib/scan-metadata.ts, src/lib/dn-pipeline/normalise-source.ts(.test.ts), src/lib/dn-pipeline/run-staged.ts(.test.ts).
|
||||
|
||||
- [x] Add failing tests: original attachments survive byte-for-byte without adding visible pages; identical content produces stable bytes; page-specific override survives normalization and creates a persistent, answerable site question.
|
||||
- [x] Implement package creation and bounded metadata validation. Preserve up to 3200-pixel scan detail through normalization. Keep answered site decisions on rerun.
|
||||
- [x] Run package and staged pipeline tests.
|
||||
|
||||
## Task 3: Camera, required crop, review and gallery
|
||||
|
||||
Files: src/app/clock/document-scanner.tsx(.test.tsx), src/app/clock/document-crop-editor.tsx, src/app/clock/document-scan-review.tsx, src/app/clock/documents-view.tsx.
|
||||
|
||||
- [x] Add tests for native still-photo input and absence of video capture, required crop/acceptance, override requirements, gallery/photo routing and all-page send gates.
|
||||
- [x] Implement native photo capture (no video preview), post-photo boundary editing/guidance, sequential photo review, image zoom, enhancement choice, explicit poor-quality reason, deterministic package upload. Keep file/PDF flow explicit.
|
||||
- [x] Verify interactions and upload retry with mocked camera/canvas/upload boundaries, without posting real records.
|
||||
|
||||
## Task 4: Verification and review
|
||||
|
||||
- [x] Run typecheck, targeted lint, focused tests and whole Vitest suite; report unrelated failures by name.
|
||||
- [x] Exercise phone-size UI with synthetic inputs; validate the actual Chamberlain sample locally when available. Do not upload it again.
|
||||
- [x] Obtain a final code review, address material findings, and commit only this task's files. Record device/deployment limitations.
|
||||
|
||||
## Final verification
|
||||
|
||||
- 4,081 tests passed across 461 files; production Next build and typecheck passed. Changed scanner client files lint clean. Existing unused MIME parameter warning remains in normalization.
|
||||
- Independent reviewer’s three P2 findings were reproduced and fixed, with regression coverage.
|
||||
- 390 × 844 crop/review/zoom and 320 × 667 no-video/native-photo UI checked in an isolated preview.
|
||||
- Actual Chamberlain original: no automatic boundary; after manual crop the approximately 879 × 897-pixel slip is flagged for distance/resolution, with Use scan disabled until an office-review reason is chosen. Nothing uploaded.
|
||||
- No business records, email workflow, QR scanners, or existing inbox edits changed. No deployment or physical-device camera test performed.
|
||||
|
|
@ -25,7 +25,7 @@ export function DocumentCropEditor({ url, initialCorners, busy, error, onApply,
|
|||
<Dialog open onOpenChange={open => { if (!open && !busy) onClose(); }}>
|
||||
<DialogContent className="max-w-lg" onEscapeKeyDown={event => { if (busy) event.preventDefault(); }} onPointerDownOutside={event => { if (busy) event.preventDefault(); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adjust paper edges</DialogTitle>
|
||||
<DialogTitle>Confirm paper edges</DialogTitle>
|
||||
<DialogDescription>Drag each corner onto the paper's corner. Include the heading and totals. You can also select a corner and use the arrow keys.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="relative mx-auto max-h-[52dvh] max-w-full" style={{ width: `min(100%, ${52 * size.width / size.height}dvh)`, aspectRatio: `${size.width} / ${size.height}` }}>
|
||||
|
|
|
|||
55
src/app/clock/document-scan-review.tsx
Normal file
55
src/app/clock/document-scan-review.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
import * as React from "react";
|
||||
import { ZoomIn } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { PaperCorners } from "@/lib/document-crop";
|
||||
import type { ScanQuality } from "@/lib/document-scan-quality";
|
||||
|
||||
export interface ScanPage {
|
||||
id: string; blob: Blob; url: string; original: Blob; originalUrl: string; originalName: string;
|
||||
corners: PaperCorners | null; cropConfirmed: boolean; accepted: boolean;
|
||||
quality: ScanQuality; mode: "colour" | "print"; qualityOverride: string;
|
||||
}
|
||||
export function DocumentScanReview({ page, busy, onAdjust, onMode, onOverride }: {
|
||||
page: ScanPage; busy: boolean; onAdjust: () => void; onMode: (mode: ScanPage["mode"]) => void; onOverride: (reason: string) => void;
|
||||
}) {
|
||||
const [original, setOriginal] = React.useState(false);
|
||||
const [zoom, setZoom] = React.useState(false);
|
||||
const [scale, setScale] = React.useState(1);
|
||||
const url = original ? page.originalUrl : page.url;
|
||||
return <div className="flex min-h-[22rem] flex-1 flex-col">
|
||||
<button type="button" aria-label="Zoom document preview" onClick={() => { setZoom(true); setScale(1); }} className="relative mx-4 min-h-40 flex-1 overflow-hidden rounded-lg bg-zinc-900 focus-visible:outline-2 focus-visible:outline-cyan-300">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url} alt={original ? "Unedited original photo" : "Cropped document scan"} className="h-full w-full object-contain" />
|
||||
<span className="absolute bottom-2 right-2 flex items-center gap-1 rounded-md bg-zinc-950/90 px-2 py-1 text-xs text-white"><ZoomIn className="h-4 w-4" />Check small print</span>
|
||||
</button>
|
||||
<div className="space-y-2 px-4 pt-2 text-sm text-white">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button type="button" disabled={busy} onClick={onAdjust} className="min-h-11 px-1 font-semibold text-cyan-300 disabled:opacity-40">{page.cropConfirmed ? "Adjust edges" : "Set paper edges"}</button>
|
||||
<button type="button" disabled={busy} onClick={() => setOriginal(!original)} className="min-h-11 px-1 text-white/80">{original ? "Show scan" : "Compare original"}</button>
|
||||
</div>
|
||||
<div role="group" aria-label="Scan appearance" className="flex gap-1 rounded-lg bg-zinc-900 p-1">
|
||||
{([['colour', 'Colour'], ['print', 'Clearer print']] as const).map(([mode, label]) => <button key={mode} type="button" aria-pressed={page.mode === mode} disabled={busy || !page.cropConfirmed} onClick={() => { setOriginal(false); onMode(mode); }} className={`min-h-10 flex-1 rounded-md px-2 text-sm font-medium disabled:opacity-40 ${page.mode === mode ? "bg-white text-zinc-950" : "text-white/75"}`}>{label}</button>)}
|
||||
</div>
|
||||
<p role="status" className={`text-xs leading-relaxed ${!page.cropConfirmed || !page.quality.ok ? "text-amber-200" : "text-white/80"}`}>{!page.cropConfirmed ? "Confirm all four paper edges before using this scan." : page.quality.message}</p>
|
||||
{page.cropConfirmed && !page.quality.ok && <label className="block space-y-1 text-xs text-amber-100">
|
||||
<span>Cannot get a clearer photo? Send for office review:</span>
|
||||
<select aria-label="Reason for sending a poor scan" value={page.qualityOverride} disabled={busy} onChange={event => onOverride(event.target.value)} className="h-11 w-full rounded-lg border border-amber-300/40 bg-zinc-900 px-2 text-base text-white">
|
||||
<option value="">Choose a reason, or retake the photo</option>
|
||||
<option value="Original paper is faint or damaged">Original paper is faint or damaged</option>
|
||||
<option value="Cannot retake now; office must verify the original">Cannot retake now; office must verify the original</option>
|
||||
</select>
|
||||
</label>}
|
||||
</div>
|
||||
<Dialog open={zoom} onOpenChange={setZoom}>
|
||||
<DialogContent className="flex max-h-[94dvh] max-w-4xl flex-col p-4">
|
||||
<DialogHeader><DialogTitle>Check the small print</DialogTitle><DialogDescription>Zoom and scroll to check the supplier, document number, quantities and totals.</DialogDescription></DialogHeader>
|
||||
<label className="flex items-center gap-3 text-sm">Zoom<input aria-label="Document zoom" type="range" min="1" max="4" step=".25" value={scale} onChange={event => setScale(Number(event.target.value))} className="flex-1" /><span>{Math.round(scale * 100)}%</span></label>
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-lg bg-zinc-900" style={{ height: "65dvh" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url} alt="Document detail" style={{ width: `${scale * 100}%`, maxWidth: "none" }} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>;
|
||||
}
|
||||
118
src/app/clock/document-scanner.test.tsx
Normal file
118
src/app/clock/document-scanner.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// @vitest-environment jsdom
|
||||
import * as React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { DocumentScanner } from "./document-scanner";
|
||||
const { upload, quality, loadPhoto } = vi.hoisted(() => ({ upload: vi.fn<(...args: unknown[]) => Promise<{ duplicate: boolean }>>(async () => ({ duplicate: false })), quality: vi.fn(() => ({ ok: true, issues: [] as string[], message: "Check the print" })), loadPhoto: vi.fn(async () => ({ width: 2200, height: 3000 })) }));
|
||||
vi.mock("./clock-documents", async original => ({ ...await original<typeof import("./clock-documents")>(), uploadClockDocument: upload }));
|
||||
vi.mock("@/lib/document-photo", () => ({
|
||||
loadDocumentPhoto: loadPhoto,
|
||||
findDocumentCorners: () => [{ x: .02, y: .02 }, { x: .98, y: .02 }, { x: .98, y: .98 }, { x: .02, y: .98 }],
|
||||
cropDocumentPhoto: (source: unknown) => source,
|
||||
measureDocumentPhoto: () => ({ sharpness: 300, brightness: 220, verdict: "good", message: "Looks sharp" }),
|
||||
inspectDocumentPhoto: quality,
|
||||
enhanceDocumentPhoto: (source: unknown) => source,
|
||||
photoBlob: async () => new Blob(["jpeg"], { type: "image/jpeg" }),
|
||||
}));
|
||||
vi.mock("@/lib/scan-pdf", () => ({ scansToPdf: async () => new Uint8Array([37, 80, 68, 70]) }));
|
||||
beforeEach(() => {
|
||||
upload.mockClear();
|
||||
quality.mockReturnValue({ ok: true, issues: [], message: "Check the print" });
|
||||
loadPhoto.mockReset().mockResolvedValue({ width: 2200, height: 3000 });
|
||||
URL.createObjectURL = vi.fn(() => `blob:${Math.random()}`); URL.revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
|
||||
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn(async () => { throw new Error("No camera"); }) } });
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
const sites = [{ id: "site1", name: "Test site", projectCode: "TEST", latitude: 0, longitude: 0 }];
|
||||
async function pickPhoto(label = "Take document photo") {
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { files: [new File(["original"], "phone.jpg", { type: "image/jpeg" })] } });
|
||||
await screen.findByRole("dialog", { name: "Confirm paper edges" });
|
||||
}
|
||||
describe("reviewing Clock document photos", () => {
|
||||
it("uses only the native still-photo input and never opens a video stream", () => {
|
||||
const { container } = render(<DocumentScanner sites={sites} />);
|
||||
const input = screen.getByLabelText("Take document photo") as HTMLInputElement;
|
||||
expect(input.getAttribute("capture")).toBe("environment");
|
||||
expect(input.accept).not.toContain("video");
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
it("can retry a failed middle gallery photo and continue the remaining selection", async () => {
|
||||
render(<DocumentScanner sites={sites} />);
|
||||
const files = ["A.jpg", "B.jpg", "C.jpg"].map(name => new File([name], name, { type: "image/jpeg" }));
|
||||
fireEvent.change(screen.getByLabelText("Choose document photos or files"), { target: { files } });
|
||||
await screen.findByRole("dialog", { name: "Confirm paper edges" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
const accept = await screen.findByRole("button", { name: "Use scan" });
|
||||
loadPhoto.mockRejectedValueOnce(new Error("Photo B cannot be decoded"));
|
||||
fireEvent.click(accept);
|
||||
const retry = await screen.findByRole("button", { name: /Retry photo/ });
|
||||
fireEvent.click(retry);
|
||||
await screen.findByRole("dialog", { name: "Confirm paper edges" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
|
||||
await screen.findByRole("dialog", { name: "Confirm paper edges" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
|
||||
expect((screen.getByRole("button", { name: "Send 3 checked pages" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
it("requires paper edges and explicit scan acceptance before upload", async () => {
|
||||
render(<DocumentScanner sites={sites} />);
|
||||
await pickPhoto();
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
await screen.findByRole("button", { name: "Use scan" });
|
||||
expect(screen.queryByRole("button", { name: /Send/ })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use scan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Send/ }));
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1));
|
||||
expect(upload.mock.calls[0]).toEqual(expect.arrayContaining(["delivery", "site1", expect.any(Blob), expect.stringMatching(/\.pdf$/)]));
|
||||
});
|
||||
it("puts selected gallery photos through the same required crop flow", async () => {
|
||||
render(<DocumentScanner sites={sites} />);
|
||||
await pickPhoto("Choose document photos or files");
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
||||
});
|
||||
it("requires a reason for a poor photo and invalidates acceptance after editing", async () => {
|
||||
quality.mockReturnValue({ ok: false, issues: ["faint"], message: "The print is faint" });
|
||||
render(<DocumentScanner sites={sites} />);
|
||||
await pickPhoto();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
const use = await screen.findByRole("button", { name: "Use scan for office review" });
|
||||
expect((use as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.change(screen.getByLabelText("Reason for sending a poor scan"), { target: { value: "Original paper is faint or damaged" } });
|
||||
fireEvent.click(use);
|
||||
expect((screen.getByRole("button", { name: /Send/ }) as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Colour" }));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /Send/ })).toBeNull());
|
||||
expect((screen.getByLabelText("Reason for sending a poor scan") as HTMLSelectElement).value).toBe("");
|
||||
});
|
||||
it("cannot send an unchecked later page from the camera screen", async () => {
|
||||
render(<DocumentScanner sites={sites} />);
|
||||
await pickPhoto();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add page" }));
|
||||
await pickPhoto();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add page" }));
|
||||
expect((screen.getByRole("button", { name: /Send/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps the accepted scan on an upload failure for retry", async () => {
|
||||
upload.mockRejectedValueOnce(new Error("No connection"));
|
||||
render(<DocumentScanner sites={sites} />);
|
||||
await pickPhoto();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply crop" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use scan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Send/ }));
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByRole("alert").textContent).toBe("No connection");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Send/ }));
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
|
||||
expect(upload.mock.calls[1][3]).toBe(upload.mock.calls[0][3]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,536 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Camera, Check, Loader2, MapPin, Plus, RotateCcw, Send, X, Upload, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ANALYSIS_WIDTH,
|
||||
assessGray,
|
||||
toGrayscale,
|
||||
isAcceptable,
|
||||
type QualityReport,
|
||||
} from "@/lib/image-quality";
|
||||
import {
|
||||
formatDistance,
|
||||
nearestSite,
|
||||
type SitePoint,
|
||||
} from "@/lib/nearest-site";
|
||||
import { imagesToPdf } from "@/lib/photo-pdf";
|
||||
import { ScannerFrame } from "./scanner-chrome";
|
||||
import { Camera, Check, FileText, Loader2, MapPin, Plus, RotateCcw, Send, Trash2, Upload, X } from "lucide-react";
|
||||
import { formatDistance, nearestSite, type SitePoint } from "@/lib/nearest-site";
|
||||
import { fullImageCorners, type PaperCorners } from "@/lib/document-crop";
|
||||
import { cropDocumentPhoto, enhanceDocumentPhoto, findDocumentCorners, inspectDocumentPhoto, loadDocumentPhoto, photoBlob } from "@/lib/document-photo";
|
||||
import { scansToPdf } from "@/lib/scan-pdf";
|
||||
import { DocumentFileDialog } from "./document-file-dialog";
|
||||
import { DOCUMENT_ACCEPT, DOCUMENT_LABELS, uploadClockDocument, type DocumentKind } from "./clock-documents";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fullImageCorners, type PaperCorners } from "@/lib/document-crop";
|
||||
import { cropDocumentPhoto, findDocumentCorners, loadDocumentPhoto, measureDocumentPhoto, photoBlob } from "@/lib/document-photo";
|
||||
import { DocumentCropEditor } from "./document-crop-editor";
|
||||
import { DocumentScanReview, type ScanPage } from "./document-scan-review";
|
||||
|
||||
/**
|
||||
* Photograph a delivery note or purchase order and send it to the inbox.
|
||||
*
|
||||
* Deliberately not the badge scanner. That one decodes QR continuously
|
||||
* and never keeps a frame; this one holds a still, measures whether it
|
||||
* is readable, and only then uploads.
|
||||
*
|
||||
* A document can run to several pages, so pages accumulate and are sent
|
||||
* together as ONE batch. Sent separately they become separate batches,
|
||||
* and nothing could then tell that page 2's line items belong to page
|
||||
* 1's note — the totals would sit on one batch and the items on
|
||||
* another. Bundled, the batch enters the pipeline exactly like a scanned
|
||||
* multi-page PDF, and segment.ts decides from the printed document
|
||||
* numbers whether it is one note over three pages or three separate
|
||||
* notes. The person holding the phone does not have to know which.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asked of the camera. Phones cap this at whatever they have — iOS
|
||||
* tends to cap video at 1920 wide — and we take what we are given. At
|
||||
* 1920 an A4 page is around 160 dpi, which print reads fine at.
|
||||
*/
|
||||
const IDEAL_WIDTH = 2560;
|
||||
const IDEAL_HEIGHT = 1440;
|
||||
|
||||
/** How often the live preview re-measures focus. */
|
||||
const SAMPLE_EVERY_MS = 750;
|
||||
|
||||
/**
|
||||
* Mirrors MAX_FILE_BYTES on /api/delivery-notes/upload. Checked here so
|
||||
* an oversized bundle is caught while the document is still in front of
|
||||
* the person, rather than as a 400 after the upload.
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/** Well past any real delivery note; a guard against a pocket-pressed button. */
|
||||
const MAX_PAGES = 12;
|
||||
|
||||
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
type Phase = "camera" | "review" | "sending" | "sent";
|
||||
|
||||
interface CapturedPage {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
report: QualityReport;
|
||||
original: Blob;
|
||||
originalUrl: string;
|
||||
corners: PaperCorners | null;
|
||||
cropStatus: "auto" | "manual" | "original" | "unclear";
|
||||
}
|
||||
const releasePage = (page: ScanPage) => { URL.revokeObjectURL(page.url); URL.revokeObjectURL(page.originalUrl); };
|
||||
|
||||
export function DocumentScanner({ sites, kind = "delivery", initialSiteId = "", onSiteChange, onBusyChange }: {
|
||||
sites: SitePoint[]; kind?: DocumentKind; initialSiteId?: string; onSiteChange?: (siteId: string) => void; onBusyChange?: (busy: boolean) => void;
|
||||
}) {
|
||||
const siteChosenRef = React.useRef(Boolean(initialSiteId));
|
||||
const sendBusyRef = React.useRef(false);
|
||||
const [capturing, setCapturing] = React.useState(false);
|
||||
const captureBusyRef = React.useRef(false);
|
||||
const mountedRef = React.useRef(false);
|
||||
const [editingPage, setEditingPage] = React.useState<CapturedPage | null>(null);
|
||||
const [cropBusy, setCropBusy] = React.useState(false);
|
||||
const cropBusyRef = React.useRef(false);
|
||||
const [cropError, setCropError] = React.useState<string | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const resetTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
const [sentDuplicate, setSentDuplicate] = React.useState(false);
|
||||
const label = DOCUMENT_LABELS[kind];
|
||||
const color = kind === "delivery" ? "#22d3ee" : "#a78bfa";
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
const workRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const fullRef = React.useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cameraInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const mountedRef = React.useRef(false);
|
||||
const busyRef = React.useRef(false);
|
||||
const sendBusyRef = React.useRef(false);
|
||||
const siteChosenRef = React.useRef(Boolean(initialSiteId));
|
||||
const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pagesRef = React.useRef<ScanPage[]>([]);
|
||||
const [pages, setPages] = React.useState<ScanPage[]>([]);
|
||||
const [phase, setPhase] = React.useState<Phase>("camera");
|
||||
const [cameraError, setCameraError] = React.useState<string | null>(null);
|
||||
const [live, setLive] = React.useState<QualityReport | null>(null);
|
||||
const [pages, setPages] = React.useState<CapturedPage[]>([]);
|
||||
const [reviewIndex, setReviewIndex] = React.useState<number | null>(null);
|
||||
const [retakeIndex, setRetakeIndex] = React.useState<number | null>(null);
|
||||
const [siteId, setSiteId] = React.useState<string>(() => initialSiteId || (sites.length === 1 ? sites[0].id : ""));
|
||||
const [siteHint, setSiteHint] = React.useState<string>(() => sites.find(site => site.id === initialSiteId)?.name ?? (sites.length === 1 ? sites[0].name : "Finding your site…"));
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [retakeId, setRetakeId] = React.useState<string | null>(null);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [photoQueue, setPhotoQueue] = React.useState<File[]>([]);
|
||||
const [queueFailed, setQueueFailed] = React.useState(false);
|
||||
const [pdfFiles, setPdfFiles] = React.useState<File[]>([]);
|
||||
const [sentDuplicate, setSentDuplicate] = React.useState(false);
|
||||
const [siteId, setSiteId] = React.useState(initialSiteId || (sites.length === 1 ? sites[0].id : ""));
|
||||
const [siteHint, setSiteHint] = React.useState(sites.find(s => s.id === initialSiteId)?.name ?? (sites.length === 1 ? sites[0].name : "Finding your site…"));
|
||||
const selected = pages.find(page => page.id === selectedId) ?? pages[pages.length - 1];
|
||||
const editing = pages.find(page => page.id === editingId);
|
||||
const canSend = pages.length > 0 && pages.every(page => page.cropConfirmed && page.accepted && (page.quality.ok || Boolean(page.qualityOverride)));
|
||||
const locked = busy || phase === "sending" || phase === "sent";
|
||||
function updatePages(change: (previous: ScanPage[]) => ScanPage[]) { pagesRef.current = change(pagesRef.current); setPages(pagesRef.current); }
|
||||
function patchPage(id: string, patch: Partial<ScanPage>) { updatePages(previous => previous.map(page => page.id === id ? { ...page, ...patch } : page)); }
|
||||
|
||||
React.useEffect(() => {
|
||||
onBusyChange?.(capturing || pages.length > 0 || files.length > 0 || phase === "sending");
|
||||
}, [capturing, pages.length, files.length, phase, onBusyChange]);
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||
};
|
||||
return () => { mountedRef.current = false; if (resetTimer.current) clearTimeout(resetTimer.current); pagesRef.current.forEach(releasePage); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => { onSiteChange?.(siteId); }, [siteId, onSiteChange]);
|
||||
|
||||
// Object URLs are revoked by hand rather than from an effect keyed on
|
||||
// `pages`: that effect's cleanup runs on every change to the array,
|
||||
// which would revoke the URL of a page still on screen. Removal and
|
||||
// a completed send each release their own; the ref exists so unmount
|
||||
// can release whatever is left without re-running on every capture.
|
||||
const pagesRef = React.useRef<CapturedPage[]>([]);
|
||||
React.useLayoutEffect(() => { pagesRef.current = pages; }, [pages]);
|
||||
const releaseAll = React.useCallback((list: CapturedPage[]) => {
|
||||
for (const p of list) { URL.revokeObjectURL(p.url); URL.revokeObjectURL(p.originalUrl); }
|
||||
}, []);
|
||||
React.useEffect(() => () => releaseAll(pagesRef.current), [releaseAll]);
|
||||
|
||||
// --- Which site are we on? ------------------------------------------
|
||||
// Geolocation proposes, the dropdown disposes. A delivery filed
|
||||
// against the wrong site lands on another site's purchase orders, so
|
||||
// the guess is never submitted without being visible.
|
||||
React.useEffect(() => { onBusyChange?.(busy || pages.length > 0 || pdfFiles.length > 0 || phase === "sending"); }, [busy, pages.length, pdfFiles.length, phase, onBusyChange]);
|
||||
React.useEffect(() => {
|
||||
if (siteChosenRef.current || sites.length === 1) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.geolocation) return;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (siteChosenRef.current) return;
|
||||
const near = nearestSite(sites, {
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
});
|
||||
if (!near) {
|
||||
setSiteHint("Pick your site");
|
||||
return;
|
||||
}
|
||||
setSiteId(near.site.id);
|
||||
setSiteHint(
|
||||
near.confident
|
||||
? `${near.site.name} · ${formatDistance(near.distanceM)}`
|
||||
: `Nearest is ${near.site.name} · ${formatDistance(near.distanceM)} — check this is right`,
|
||||
);
|
||||
},
|
||||
() => { if (!siteChosenRef.current) setSiteHint("Location off — pick your site"); },
|
||||
{ enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 },
|
||||
);
|
||||
if (siteChosenRef.current || sites.length === 1 || !navigator.geolocation) return;
|
||||
navigator.geolocation.getCurrentPosition(position => {
|
||||
if (siteChosenRef.current || !mountedRef.current) return;
|
||||
const near = nearestSite(sites, { latitude: position.coords.latitude, longitude: position.coords.longitude });
|
||||
if (!near) { setSiteHint("Pick your site"); return; }
|
||||
setSiteId(near.site.id);
|
||||
setSiteHint(near.confident ? `${near.site.name} · ${formatDistance(near.distanceM)}` : `Nearest is ${near.site.name}. Check this is right.`);
|
||||
}, () => { if (mountedRef.current && !siteChosenRef.current) setSiteHint("Location off. Pick your site."); }, { enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 });
|
||||
}, [sites]);
|
||||
|
||||
// --- Live camera + focus sampling ------------------------------------
|
||||
React.useEffect(() => {
|
||||
if (phase !== "camera" || files.length > 0) return;
|
||||
let stream: MediaStream | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let cancelled = false;
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
|
||||
function sample() {
|
||||
const video = videoRef.current;
|
||||
const work = workRef.current;
|
||||
if (!video || !work || video.readyState < video.HAVE_CURRENT_DATA) return;
|
||||
|
||||
// Find the page across the full camera image, including outside the guide.
|
||||
work.width = 320;
|
||||
work.height = Math.max(2, Math.round(video.videoHeight * 320 / video.videoWidth));
|
||||
const ctx = work.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(video, 0, 0, work.width, work.height);
|
||||
const corners = findDocumentCorners(work);
|
||||
// Sample the detected page directly from the full-resolution video.
|
||||
// Enlarging the 320px detection image would invent a blur warning.
|
||||
const left = corners ? Math.min(...corners.map(p => p.x)) : 0;
|
||||
const top = corners ? Math.min(...corners.map(p => p.y)) : 0;
|
||||
const width = (corners ? Math.max(...corners.map(p => p.x)) - left : 1) * video.videoWidth;
|
||||
const height = (corners ? Math.max(...corners.map(p => p.y)) - top : 1) * video.videoHeight;
|
||||
work.width = ANALYSIS_WIDTH;
|
||||
work.height = Math.max(2, Math.round(height * ANALYSIS_WIDTH / width));
|
||||
ctx.drawImage(video, left * video.videoWidth, top * video.videoHeight, width, height, 0, 0, work.width, work.height);
|
||||
setLive(assessGray(toGrayscale(ctx.getImageData(0, 0, work.width, work.height).data, work.width, work.height), work.width, work.height));
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError("This browser cannot open the camera.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: "environment" },
|
||||
width: { ideal: IDEAL_WIDTH },
|
||||
height: { ideal: IDEAL_HEIGHT },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setCameraError("Camera access denied or unavailable.");
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play().catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
wakeLock = (await navigator.wakeLock?.request("screen")) ?? null;
|
||||
} catch {
|
||||
// Not fatal — the screen may simply dim.
|
||||
}
|
||||
timer = setInterval(sample, SAMPLE_EVERY_MS);
|
||||
}
|
||||
|
||||
start();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearInterval(timer);
|
||||
wakeLock?.release().catch(() => undefined);
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, [phase, files.length]);
|
||||
|
||||
async function capture() {
|
||||
const video = videoRef.current;
|
||||
const full = fullRef.current;
|
||||
if (!video || !full || captureBusyRef.current || (pages.length >= MAX_PAGES && retakeIndex === null) || video.readyState < video.HAVE_CURRENT_DATA) return;
|
||||
const fctx = full.getContext("2d", { willReadFrequently: true });
|
||||
if (!fctx) return;
|
||||
captureBusyRef.current = true;
|
||||
setCapturing(true);
|
||||
setError(null);
|
||||
async function addPhoto(original: Blob, originalName: string, replacingId: string | null = null) {
|
||||
if (busyRef.current || sendBusyRef.current) return false;
|
||||
busyRef.current = true; setBusy(true); setError(null);
|
||||
try {
|
||||
// Freeze the WHOLE camera image. The guide never limits detection.
|
||||
full.width = video.videoWidth;
|
||||
full.height = video.videoHeight;
|
||||
fctx.drawImage(video, 0, 0);
|
||||
await keepCapturedPhoto(full);
|
||||
} catch (captureError) {
|
||||
setError(captureError instanceof Error ? captureError.message : "Could not capture the photo — try again.");
|
||||
} finally {
|
||||
captureBusyRef.current = false;
|
||||
setCapturing(false);
|
||||
}
|
||||
if (original.size > MAX_UPLOAD_BYTES) throw new Error("Choose a photo smaller than 20 MB.");
|
||||
const source = await loadDocumentPhoto(original);
|
||||
const corners = findDocumentCorners(source);
|
||||
const cropped = corners ? cropDocumentPhoto(source, corners) : source;
|
||||
const blob = await photoBlob(cropped);
|
||||
if (!mountedRef.current) return;
|
||||
const quality = inspectDocumentPhoto(source, corners, cropped);
|
||||
const page: ScanPage = { id: crypto.randomUUID(), original, originalName, originalUrl: URL.createObjectURL(original), blob, url: URL.createObjectURL(blob), corners, cropConfirmed: false, accepted: false, mode: "print", qualityOverride: "", quality };
|
||||
const replacing = pagesRef.current.find(item => item.id === replacingId);
|
||||
updatePages(previous => replacing ? previous.map(item => item.id === replacingId ? page : item) : [...previous, page]);
|
||||
if (replacing) releasePage(replacing);
|
||||
setSelectedId(page.id); setEditingId(page.id); setRetakeId(null); setPhase("review");
|
||||
navigator.vibrate?.(30);
|
||||
return true;
|
||||
} catch (failure) { if (mountedRef.current) setError(`Could not open ${originalName}. ${failure instanceof Error ? failure.message : "Try again."}`); return false; }
|
||||
finally { busyRef.current = false; if (mountedRef.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function keepCapturedPhoto(source: HTMLCanvasElement) {
|
||||
const corners = findDocumentCorners(source);
|
||||
const output = corners ? cropDocumentPhoto(source, corners) : source;
|
||||
const report = measureDocumentPhoto(output);
|
||||
const original = await photoBlob(source);
|
||||
const blob = corners ? await photoBlob(output) : original;
|
||||
if (!mountedRef.current) return;
|
||||
const page: CapturedPage = {
|
||||
blob, url: URL.createObjectURL(blob), report, original,
|
||||
originalUrl: URL.createObjectURL(original), corners, cropStatus: corners ? "auto" : "unclear",
|
||||
};
|
||||
const replacing = retakeIndex === null ? null : pagesRef.current[retakeIndex];
|
||||
if (replacing) releaseAll([replacing]);
|
||||
setPages(previous => replacing ? previous.map(item => item === replacing ? page : item) : [...previous, page]);
|
||||
setReviewIndex(replacing ? retakeIndex : null);
|
||||
setRetakeIndex(null);
|
||||
setPhase("review");
|
||||
navigator.vibrate?.(40);
|
||||
}
|
||||
|
||||
async function captureNativePhoto(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || captureBusyRef.current || (pages.length >= MAX_PAGES && retakeIndex === null)) return;
|
||||
captureBusyRef.current = true;
|
||||
setCapturing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await keepCapturedPhoto(await loadDocumentPhoto(file));
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "Could not open the photo — try again.");
|
||||
} finally {
|
||||
captureBusyRef.current = false;
|
||||
setCapturing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCrop(page: CapturedPage, corners: PaperCorners | null) {
|
||||
if (cropBusyRef.current) return;
|
||||
cropBusyRef.current = true;
|
||||
setCropBusy(true);
|
||||
setCropError(null);
|
||||
async function renderPage(page: ScanPage, corners: PaperCorners, mode: ScanPage["mode"]) {
|
||||
if (busyRef.current || sendBusyRef.current) return;
|
||||
busyRef.current = true; setBusy(true); setError(null);
|
||||
try {
|
||||
const source = await loadDocumentPhoto(page.original);
|
||||
const output = corners ? cropDocumentPhoto(source, corners) : source;
|
||||
const report = measureDocumentPhoto(output);
|
||||
const blob = corners ? await photoBlob(output) : page.original;
|
||||
if (!mountedRef.current || !pagesRef.current.includes(page)) return;
|
||||
const replacement: CapturedPage = { ...page, blob, url: URL.createObjectURL(blob), report, corners, cropStatus: corners ? "manual" : "original" };
|
||||
setPages(previous => previous.map(item => item === page ? replacement : item));
|
||||
URL.revokeObjectURL(page.url);
|
||||
setEditingPage(null);
|
||||
setError(null);
|
||||
} catch (cropFailure) {
|
||||
const message = cropFailure instanceof Error ? cropFailure.message : "Could not crop the photo — try again.";
|
||||
setCropError(message);
|
||||
setError(message);
|
||||
} finally {
|
||||
cropBusyRef.current = false;
|
||||
setCropBusy(false);
|
||||
}
|
||||
const cropped = cropDocumentPhoto(source, corners);
|
||||
const quality = inspectDocumentPhoto(source, corners, cropped);
|
||||
const output = mode === "print" ? enhanceDocumentPhoto(cropped) : cropped;
|
||||
const blob = await photoBlob(output);
|
||||
if (!mountedRef.current || !pagesRef.current.some(item => item.id === page.id)) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
patchPage(page.id, { blob, url, corners, mode, quality, cropConfirmed: true, accepted: false, qualityOverride: "" });
|
||||
URL.revokeObjectURL(page.url); setEditingId(null);
|
||||
} catch (failure) { if (mountedRef.current) setError(failure instanceof Error ? failure.message : "Could not process the scan. Try again."); }
|
||||
finally { busyRef.current = false; if (mountedRef.current) setBusy(false); }
|
||||
}
|
||||
|
||||
/** Drop a page — a mis-shot, or one taken twice. */
|
||||
function removePage(index: number) {
|
||||
setPages((prev) => {
|
||||
const target = prev[index];
|
||||
if (target) { URL.revokeObjectURL(target.url); URL.revokeObjectURL(target.originalUrl); }
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
setError(null);
|
||||
setReviewIndex(null);
|
||||
setRetakeIndex(null);
|
||||
if (pages.length === 1) setPhase("camera");
|
||||
function useScan() {
|
||||
if (!selected?.cropConfirmed || (!selected.quality.ok && !selected.qualityOverride) || locked) return;
|
||||
patchPage(selected.id, { accepted: true });
|
||||
if (photoQueue.length) void importQueued(photoQueue);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (pages.length === 0 || !siteId || sendBusyRef.current || cropBusyRef.current || captureBusyRef.current || editingPage) return;
|
||||
sendBusyRef.current = true;
|
||||
setPhase("sending");
|
||||
setError(null);
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
let file: Blob;
|
||||
let filename: string;
|
||||
try {
|
||||
if (pages.length === 1) {
|
||||
// One page goes up as its own JPEG: smaller, and it never loads
|
||||
// the PDF library at all.
|
||||
file = pages[0].blob;
|
||||
filename = `site-capture-${stamp}.jpg`;
|
||||
} else {
|
||||
const jpegs = await Promise.all(
|
||||
pages.map(async (p) => new Uint8Array(await p.blob.arrayBuffer())),
|
||||
);
|
||||
const { bytes } = await imagesToPdf(jpegs);
|
||||
file = new Blob([bytes as BlobPart], { type: "application/pdf" });
|
||||
filename = `site-capture-${stamp}-${pages.length}pages.pdf`;
|
||||
}
|
||||
} catch {
|
||||
sendBusyRef.current = false;
|
||||
setError("Could not put the pages together — try sending fewer");
|
||||
setPhase("review");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
sendBusyRef.current = false;
|
||||
setError(
|
||||
`Too big to send (${Math.round(file.size / 1024 / 1024)} MB). Send these pages, then start a new one.`,
|
||||
);
|
||||
setPhase("review");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadClockDocument(kind, siteId, file, filename);
|
||||
setSentDuplicate(result.duplicate);
|
||||
setPhase("sent");
|
||||
navigator.vibrate?.([80, 60, 80]);
|
||||
const sentPages = pages;
|
||||
resetTimerRef.current = setTimeout(() => {
|
||||
releaseAll(sentPages);
|
||||
setPages([]);
|
||||
setLive(null);
|
||||
sendBusyRef.current = false;
|
||||
setPhase("camera");
|
||||
}, 2200);
|
||||
} catch (uploadError) {
|
||||
sendBusyRef.current = false;
|
||||
setError(uploadError instanceof Error ? uploadError.message : "No signal — try again when you have a connection");
|
||||
setPhase("review");
|
||||
}
|
||||
async function importQueued(queue: File[]) {
|
||||
if (!queue.length || busyRef.current || sendBusyRef.current) return;
|
||||
setPhotoQueue(queue); setQueueFailed(false);
|
||||
const [next, ...rest] = queue;
|
||||
const added = await addPhoto(next, next.name);
|
||||
if (!mountedRef.current) return;
|
||||
setPhotoQueue(added ? rest : queue); setQueueFailed(!added);
|
||||
}
|
||||
|
||||
const reviewingIndex = reviewIndex ?? pages.length - 1;
|
||||
const justTaken = phase === "review" ? pages[reviewingIndex] : null;
|
||||
const activeReport = phase === "camera" ? live : justTaken?.report ?? null;
|
||||
const atPageLimit = pages.length >= MAX_PAGES && retakeIndex === null;
|
||||
|
||||
function skipQueuedPhoto() {
|
||||
const [, ...rest] = photoQueue;
|
||||
setPhotoQueue(rest); setQueueFailed(false); setError(null);
|
||||
if (rest.length) void importQueued(rest);
|
||||
}
|
||||
function removePage(id: string) {
|
||||
if (locked) return;
|
||||
const page = pagesRef.current.find(item => item.id === id);
|
||||
if (page) releasePage(page);
|
||||
updatePages(previous => previous.filter(item => item.id !== id));
|
||||
setSelectedId(null); setEditingId(null); setRetakeId(null); setPhotoQueue([]); setQueueFailed(false); setError(null);
|
||||
if (!pagesRef.current.length) setPhase("camera");
|
||||
}
|
||||
function discard() { pagesRef.current.forEach(releasePage); updatePages(() => []); setSelectedId(null); setEditingId(null); setRetakeId(null); setPhotoQueue([]); setQueueFailed(false); setError(null); setPhase("camera"); }
|
||||
function pickFiles(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = Array.from(event.target.files ?? []);
|
||||
if (picked.length > 20) {
|
||||
setError("Choose up to 20 files at a time.");
|
||||
} else {
|
||||
setFiles(picked);
|
||||
setError(null);
|
||||
const picked = Array.from(event.target.files ?? []); event.target.value = "";
|
||||
if (!picked.length || locked) return;
|
||||
const pdfs = picked.filter(file => file.type === "application/pdf");
|
||||
if (pdfs.length && pdfs.length !== picked.length) { setError("Choose photos together, or PDFs separately."); return; }
|
||||
if (pdfs.length) {
|
||||
if (pages.length) { setError("Send or discard your scan before choosing a PDF."); return; }
|
||||
if (pdfs.length > 20) { setError("Choose up to 20 PDFs at a time."); return; }
|
||||
setPdfFiles(pdfs); return;
|
||||
}
|
||||
event.target.value = "";
|
||||
if (picked.some(file => !["image/jpeg", "image/png", "image/webp"].includes(file.type))) { setError("Choose JPG, PNG or WebP photos, or a PDF."); return; }
|
||||
if (pages.length + picked.length > MAX_PAGES) { setError("A scan can have up to 12 pages. Send this document first."); return; }
|
||||
void importQueued(picked);
|
||||
}
|
||||
|
||||
function discard() {
|
||||
releaseAll(pages);
|
||||
setPages([]); setReviewIndex(null); setRetakeIndex(null); setError(null); setPhase("camera");
|
||||
async function send() {
|
||||
// Recheck every page here; neither camera mode nor an earlier page can bypass review.
|
||||
if (busyRef.current || sendBusyRef.current || editingId || photoQueue.length || !siteId || !pagesRef.current.length || pagesRef.current.some(page => !page.cropConfirmed || !page.accepted || (!page.quality.ok && !page.qualityOverride))) return;
|
||||
sendBusyRef.current = true; setPhase("sending"); setError(null);
|
||||
try {
|
||||
const packaged = await scansToPdf(await Promise.all(pagesRef.current.map(async page => ({ jpeg: new Uint8Array(await page.blob.arrayBuffer()), original: new Uint8Array(await page.original.arrayBuffer()), originalName: page.originalName, originalMime: page.original.type || "image/jpeg", qualityOverride: page.quality.ok ? "" : `${page.qualityOverride}. Warnings: ${page.quality.issues.join(", ")}` }))));
|
||||
if (packaged.byteLength > MAX_UPLOAD_BYTES) throw new Error("The scan and original photos exceed 20 MB. Remove some pages and send them in another document.");
|
||||
const result = await uploadClockDocument(kind, siteId, new Blob([packaged as BlobPart], { type: "application/pdf" }), `site-scan-${pagesRef.current[0].id}.pdf`);
|
||||
if (!mountedRef.current) return;
|
||||
setSentDuplicate(result.duplicate); setPhase("sent"); navigator.vibrate?.([80, 60, 80]);
|
||||
resetTimer.current = setTimeout(() => { discard(); sendBusyRef.current = false; }, 2200);
|
||||
} catch (failure) { sendBusyRef.current = false; if (mountedRef.current) { setPhase("review"); setError(failure instanceof Error ? failure.message : "Could not send. Your scan is kept here; try again."); } }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-black">
|
||||
<video ref={videoRef} muted playsInline className={cn("absolute inset-0 h-full w-full object-cover", phase !== "camera" && "invisible")} />
|
||||
<canvas ref={workRef} className="hidden" /><canvas ref={fullRef} className="hidden" />
|
||||
<input ref={fileInputRef} type="file" accept={DOCUMENT_ACCEPT} multiple onChange={pickFiles} className="hidden" aria-label="Choose document photos or files" />
|
||||
<input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={event => void captureNativePhoto(event)} className="hidden" aria-label="Take document photo" />
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<SiteBar sites={sites} siteId={siteId} hint={siteHint} label={label} disabled={phase === "sending" || phase === "sent"} onChange={id => {
|
||||
siteChosenRef.current = true; setSiteId(id); const site = sites.find(item => item.id === id); if (site) setSiteHint(site.name);
|
||||
}} />
|
||||
<div className="shrink-0 px-5 pb-2 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide" style={{ color }}>{label}{pages.length > 0 ? ` · ${pages.length} page${pages.length === 1 ? "" : "s"}` : ""}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/85">{phase === "camera" ? retakeIndex !== null ? `Photograph page ${retakeIndex + 1} again` : pages.length ? `Photograph page ${pages.length + 1} of the same document` : "Keep all paper edges visible. The guide helps you aim; we crop to the paper." : "Check the crop includes the whole page, heading and totals"}</p>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 px-5">
|
||||
{phase === "camera" ? <ScannerFrame color={live && !cameraError ? isAcceptable(live) ? "#34d399" : "#f87171" : color} shape="document" error={cameraError ? `${cameraError} Take a photo with your phone camera or choose a file below.` : null} /> : justTaken && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={justTaken.url} alt={`Page ${reviewingIndex + 1} preview`} className="h-full w-full rounded-xl object-contain" />
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2 bg-black/75 px-5 pb-4 pt-3">
|
||||
{pages.length > 0 && phase !== "sent" && <PageStrip pages={pages} onSelect={index => { setReviewIndex(index); setRetakeIndex(null); setPhase("review"); }} onRemove={removePage} busy={phase === "sending" || cropBusy || capturing} />}
|
||||
{activeReport && phase !== "sent" && <p className={cn("text-center text-xs font-medium", isAcceptable(activeReport) ? "text-emerald-300" : "text-red-300")}>{activeReport.message}</p>}
|
||||
{error && <p role="alert" className="text-center text-sm text-red-300">{error}</p>}
|
||||
{phase === "camera" && <>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => cameraError ? cameraInputRef.current?.click() : void capture()} disabled={!siteId || atPageLimit || files.length > 0 || capturing} className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-white px-3 text-sm font-semibold text-black disabled:opacity-40">
|
||||
<Camera className="h-4 w-4 shrink-0" />{capturing ? "Finding paper edges…" : retakeIndex !== null ? `Retake page ${retakeIndex + 1}` : pages.length ? `Take page ${pages.length + 1}` : "Take photo"}
|
||||
</button>
|
||||
{pages.length > 0 ? <button type="button" onClick={() => { if (retakeIndex !== null) { setReviewIndex(retakeIndex); setRetakeIndex(null); setPhase("review"); } else void send(); }} disabled={!siteId || capturing} className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-500 px-3 text-sm font-semibold text-white disabled:opacity-40"><Send className="h-4 w-4" />{retakeIndex !== null ? "Cancel retake" : `Send ${pages.length} pages`}</button> : <button type="button" onClick={() => fileInputRef.current?.click()} disabled={capturing} className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-white/10 px-3 text-sm font-semibold text-white ring-1 ring-white/25"><Upload className="h-4 w-4 shrink-0" />Choose files</button>}
|
||||
</div>
|
||||
{!siteId && <p className="text-center text-xs text-white/65">Choose a site above to take a photo.</p>}
|
||||
</>}
|
||||
{(phase === "review" || phase === "sending") && <>
|
||||
{justTaken && <>
|
||||
<p className="text-center text-xs text-white/75">{justTaken.cropStatus === "original" ? "Original photo restored" : justTaken.corners ? "Cropped to paper — check all edges" : "Paper edges unclear — adjust crop or keep the original"}</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button type="button" disabled={phase === "sending" || cropBusy} onClick={() => { setCropError(null); setEditingPage(justTaken); }} className="min-h-11 text-sm font-medium text-cyan-300 disabled:opacity-40">Adjust crop</button>
|
||||
{justTaken.corners && <button type="button" disabled={phase === "sending" || cropBusy} onClick={() => void updateCrop(justTaken, null)} className="min-h-11 text-sm text-white/75 disabled:opacity-40">Use original</button>}
|
||||
</div>
|
||||
</>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { setRetakeIndex(reviewingIndex); setPhase("camera"); }} disabled={phase === "sending" || cropBusy} className="flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-white/10 text-sm font-semibold text-white disabled:opacity-40"><RotateCcw className="h-4 w-4" />Retake</button>
|
||||
<button type="button" onClick={() => setPhase("camera")} disabled={phase === "sending" || cropBusy || atPageLimit} className="flex h-11 flex-1 items-center justify-center gap-2 rounded-xl bg-white/10 text-sm font-semibold text-white disabled:opacity-40"><Plus className="h-4 w-4" />Add page</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => void send()} disabled={phase === "sending" || cropBusy || !pages.length || !siteId} className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 px-3 text-sm font-semibold text-white disabled:opacity-40">
|
||||
{phase === "sending" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}{phase === "sending" ? "Sending…" : justTaken && !isAcceptable(justTaken.report) ? "Send anyway" : `Send ${pages.length === 1 ? label.toLowerCase() : `all ${pages.length} pages`}`}
|
||||
</button>
|
||||
</>}
|
||||
{pages.length > 0 && phase !== "sending" && phase !== "sent" && <button type="button" disabled={cropBusy || capturing} onClick={discard} className="flex min-h-9 w-full items-center justify-center gap-1.5 text-xs text-white/60"><Trash2 className="h-3.5 w-3.5" />Discard document</button>}
|
||||
</div>
|
||||
{phase === "sent" && <div role="status" className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-emerald-700/95 p-6 text-center text-white">
|
||||
<Check className="h-12 w-12" /><p className="text-xl font-bold">{sentDuplicate ? "Already uploaded" : `${label} sent to the office`}</p><p className="text-sm">{sentDuplicate ? "The existing document will be used." : "Queued for processing. Ready for the next document."}</p>
|
||||
</div>}
|
||||
function changeSite(id: string) { siteChosenRef.current = true; setSiteId(id); setSiteHint(sites.find(site => site.id === id)?.name ?? "Pick your site"); }
|
||||
const sendButton = <button type="button" onClick={() => void send()} disabled={locked || !canSend || !siteId || photoQueue.length > 0} className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 px-3 text-sm font-semibold text-zinc-950 disabled:opacity-40">{phase === "sending" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}{phase === "sending" ? "Sending…" : `Send ${pages.length} checked page${pages.length === 1 ? "" : "s"}`}</button>;
|
||||
return <div className="relative flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto bg-zinc-950 text-white">
|
||||
<input ref={fileInputRef} type="file" accept={DOCUMENT_ACCEPT} multiple onChange={pickFiles} className="hidden" aria-label="Choose document photos or files" />
|
||||
<input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (file && !locked && (pages.length < MAX_PAGES || retakeId)) void addPhoto(file, file.name, retakeId); }} className="hidden" aria-label="Take document photo" />
|
||||
{pages.length > 0 ? <div className="flex min-h-10 shrink-0 items-center justify-center gap-2 px-4 text-xs text-white/65"><MapPin className="h-3.5 w-3.5" />{sites.find(site => site.id === siteId)?.name ?? siteHint}</div> : <SiteBar sites={sites} siteId={siteId} hint={siteHint} label={label} disabled={locked} onChange={changeSite} />}
|
||||
<div className="shrink-0 px-4 pb-2 text-center"><p className="text-xs font-semibold uppercase tracking-wide text-cyan-300">{label}{pages.length ? ` · page ${Math.max(0, pages.indexOf(selected)) + 1} of ${pages.length}` : ""}</p><p className="mt-1 text-sm text-white/85">{phase === "camera" ? "One page at a time. Move close and keep every edge visible." : "Check the print. Tap the scan to zoom."}</p></div>
|
||||
{phase === "camera" ? <div className="flex min-h-52 flex-1 flex-col items-center justify-center gap-4 px-6 py-4 text-center">
|
||||
<div aria-hidden className="relative flex aspect-[3/4] max-h-56 w-36 items-center justify-center rounded-lg border border-white/20 bg-zinc-900">
|
||||
<FileText className="h-16 w-16 text-white/45" strokeWidth={1} />
|
||||
<span className="absolute -left-px -top-px h-6 w-6 rounded-tl-lg border-l-2 border-t-2 border-cyan-300" /><span className="absolute -right-px -top-px h-6 w-6 rounded-tr-lg border-r-2 border-t-2 border-cyan-300" />
|
||||
<span className="absolute -bottom-px -left-px h-6 w-6 rounded-bl-lg border-b-2 border-l-2 border-cyan-300" /><span className="absolute -bottom-px -right-px h-6 w-6 rounded-br-lg border-b-2 border-r-2 border-cyan-300" />
|
||||
</div>
|
||||
{editingPage && <DocumentCropEditor url={editingPage.originalUrl} initialCorners={editingPage.corners ?? fullImageCorners()} busy={cropBusy} error={cropError} onApply={corners => void updateCrop(editingPage, corners)} onClose={() => setEditingPage(null)} />}
|
||||
{files.length > 0 && <DocumentFileDialog files={files} kind={kind} sites={sites} siteId={siteId} onSiteChange={id => { siteChosenRef.current = true; setSiteId(id); setSiteHint(sites.find(site => site.id === id)?.name ?? "Choose a site"); }} onClose={() => setFiles([])} />}
|
||||
<p className="max-w-xs text-sm leading-relaxed text-white/75">Lay the paper flat in even light. Fill the photo with one page, keeping all four edges visible.</p>
|
||||
<p className="max-w-xs text-xs leading-relaxed text-cyan-200">After the photo, adjust the paper edges and check the small print.</p>
|
||||
</div> : selected && <DocumentScanReview key={`review-${selected.id}`} page={selected} busy={locked} onAdjust={() => { setError(null); setEditingId(selected.id); }} onMode={mode => selected.corners && void renderPage(selected, selected.corners, mode)} onOverride={qualityOverride => patchPage(selected.id, { qualityOverride, accepted: false })} />}
|
||||
<div className="shrink-0 space-y-2 bg-zinc-950 px-4 pb-3 pt-2">
|
||||
{pages.length > 1 && phase !== "sent" && <div className="flex gap-2 overflow-x-auto pb-1" aria-label="Document pages">{pages.map((page, index) => <div key={page.id} className="relative shrink-0"><button type="button" aria-label={`Review page ${index + 1}${page.accepted ? ", checked" : ", needs checking"}`} disabled={locked} onClick={() => { setSelectedId(page.id); setRetakeId(null); setPhase("review"); }} className={`h-11 rounded-lg border px-3 text-sm ${page.accepted ? "border-emerald-400 text-emerald-200" : "border-amber-300 text-amber-200"}`}>{index + 1} {page.accepted ? "✓" : "Check"}</button><button type="button" aria-label={`Remove page ${index + 1}`} disabled={locked} onClick={() => removePage(page.id)} className="ml-1 inline-flex h-11 w-8 items-center justify-center text-white/60"><X className="h-4 w-4" /></button></div>)}</div>}
|
||||
{error && <p role="alert" className="text-sm leading-relaxed text-red-200">{error}</p>}
|
||||
{queueFailed && photoQueue.length > 0 && <div className="flex gap-2"><button type="button" disabled={locked} onClick={() => void importQueued(photoQueue)} className="min-h-11 flex-1 rounded-lg bg-amber-200 px-2 text-sm font-semibold text-zinc-950 disabled:opacity-40">Retry photo</button><button type="button" disabled={locked} onClick={skipQueuedPhoto} className="min-h-11 flex-1 rounded-lg border border-white/30 px-2 text-sm disabled:opacity-40">Skip this photo</button></div>}
|
||||
{phase === "camera" ? <>
|
||||
<button type="button" disabled={locked || !siteId || (pages.length >= MAX_PAGES && !retakeId)} onClick={() => cameraInputRef.current?.click()} className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-white text-sm font-semibold text-zinc-950 disabled:opacity-40"><Camera className="h-4 w-4" />{busy ? "Opening photo…" : retakeId ? "Retake page" : "Take photo"}</button>
|
||||
<button type="button" disabled={locked} onClick={() => fileInputRef.current?.click()} className="flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-white/10 text-sm disabled:opacity-40"><Upload className="h-4 w-4" />Choose photos or PDFs</button>
|
||||
{!siteId && <p className="text-center text-xs text-white/65">Choose your site above to take a photo.</p>}
|
||||
{pages.length > 0 && <>{sendButton}<button type="button" disabled={locked} onClick={() => { setRetakeId(null); setPhase("review"); }} className="min-h-10 w-full text-sm text-white/75">Back to scan</button></>}
|
||||
{!pages.length && <p className="text-center text-xs text-white/55">Selected photos become pages of one document. Choose PDFs separately.</p>}
|
||||
</> : phase !== "sent" && <>
|
||||
{selected && !selected.accepted && <button type="button" disabled={locked || !selected.cropConfirmed || (!selected.quality.ok && !selected.qualityOverride)} onClick={useScan} className="h-12 w-full rounded-xl bg-white px-3 text-sm font-semibold text-zinc-950 disabled:opacity-40">{busy ? "Processing scan…" : selected.quality.ok ? "Use scan" : "Use scan for office review"}</button>}
|
||||
<div className="flex gap-2"><button type="button" disabled={locked} onClick={() => { setRetakeId(selected?.id ?? null); setPhase("camera"); }} className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg bg-white/10 text-sm disabled:opacity-40"><RotateCcw className="h-4 w-4" />Retake</button><button type="button" disabled={locked || pages.length >= MAX_PAGES || photoQueue.length > 0} onClick={() => { setRetakeId(null); setPhase("camera"); }} className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg bg-white/10 text-sm disabled:opacity-40"><Plus className="h-4 w-4" />Add page</button></div>
|
||||
{selected?.accepted && sendButton}
|
||||
</>}
|
||||
{pages.length > 0 && phase !== "sent" && <button type="button" disabled={locked} onClick={discard} className="flex min-h-9 w-full items-center justify-center gap-2 text-xs text-white/60 disabled:opacity-40"><Trash2 className="h-3.5 w-3.5" />Discard document</button>}
|
||||
</div>
|
||||
);
|
||||
{phase === "sent" && <div role="status" className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-emerald-800 p-6 text-center text-white"><Check className="h-12 w-12" /><p className="text-xl font-bold">{sentDuplicate ? "Already uploaded" : `${label} sent to the office`}</p><p className="text-sm">{sentDuplicate ? "The existing document will be used." : "Scan and original photos received. Ready for the next document."}</p></div>}
|
||||
{editing && <DocumentCropEditor key={`crop-${editing.id}`} url={editing.originalUrl} initialCorners={editing.corners ?? fullImageCorners()} busy={busy} error={error} onApply={corners => void renderPage(editing, corners, editing.mode)} onClose={() => setEditingId(null)} />}
|
||||
{pdfFiles.length > 0 && <DocumentFileDialog files={pdfFiles} kind={kind} sites={sites} siteId={siteId} onSiteChange={changeSite} onClose={() => setPdfFiles([])} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages taken so far, oldest first.
|
||||
*
|
||||
* Present so nobody has to remember whether they got page 2, and so a
|
||||
* mis-shot can be thrown away without starting the document again.
|
||||
* A page that measured poorly is ringed in red, which is the only place
|
||||
* an earlier page's quality is visible once it scrolls out of review.
|
||||
*/
|
||||
function PageStrip({
|
||||
pages,
|
||||
onRemove,
|
||||
onSelect,
|
||||
busy,
|
||||
}: {
|
||||
pages: CapturedPage[];
|
||||
onRemove: (index: number) => void;
|
||||
onSelect: (index: number) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{pages.map((p, i) => (
|
||||
<div key={p.url} className="relative shrink-0">
|
||||
<button type="button" aria-label={`Review page ${i + 1}`} disabled={busy} onClick={() => onSelect(i)} className="block rounded-md focus-visible:outline-2 focus-visible:outline-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`Page ${i + 1}`}
|
||||
className={cn(
|
||||
"h-16 w-12 rounded-md object-cover ring-2",
|
||||
isAcceptable(p.report) ? "ring-emerald-400/70" : "ring-red-400/80",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute bottom-0 left-0 rounded-br-md rounded-tl-md bg-black/70 px-1 text-[10px] font-bold text-white">
|
||||
{i + 1}
|
||||
</span>
|
||||
{!busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(i)}
|
||||
aria-label={`Remove page ${i + 1}`}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-black text-white ring-1 ring-white/40"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteBar({
|
||||
sites,
|
||||
siteId,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@ it("opens Documents on delivery notes with both photo and file actions", () => {
|
|||
const selected = html.match(/<button[^>]*aria-pressed="true"[^>]*>[\s\S]*?<\/button>/)?.[0];
|
||||
expect(selected).toContain("Delivery notes");
|
||||
expect(html).toContain("Take photo");
|
||||
expect(html).toContain("Choose files");
|
||||
expect(html).toContain("Choose photos or PDFs");
|
||||
expect(html).toContain("Site for this delivery note");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function DocumentsView({ sites }: { sites: SitePoint[] }) {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
{busy && <p className="mt-2 text-center text-xs text-white/60">Send or discard the current document before changing its type.</p>}
|
||||
{busy && <p className="sr-only" role="status">Send or discard the current document before changing its type.</p>}
|
||||
</div>
|
||||
<DocumentScanner key={kind} sites={sites} kind={kind} initialSiteId={siteId} onSiteChange={setSiteId} onBusyChange={setBusy} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,23 @@ import { describe, expect, it } from "vitest";
|
|||
import sharp from "sharp";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { normaliseSource } from "./normalise-source";
|
||||
import { scansToPdf } from "../scan-pdf";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
describe("normaliseSource", () => {
|
||||
it("still renders owner-protected PDFs that the metadata parser cannot open", async () => {
|
||||
const PDFKit = createRequire(import.meta.url)("@foliojs-fork/pdfkit");
|
||||
const input = await new Promise<Buffer>((resolve, reject) => {
|
||||
const doc = new PDFKit({ size: [100, 100], margin: 5, ownerPassword: "owner-only", userPassword: "" });
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject);
|
||||
doc.fontSize(8).text("Readable PDF", 5, 10); doc.end();
|
||||
});
|
||||
const pages = await normaliseSource(input, "application/pdf");
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0].width).toBeGreaterThan(100);
|
||||
});
|
||||
it("returns a real PNG for JPEG input regardless of declared MIME", async () => {
|
||||
const jpeg = await sharp({
|
||||
create: { width: 80, height: 120, channels: 3, background: "white" },
|
||||
|
|
@ -49,12 +64,20 @@ describe("normaliseSource", () => {
|
|||
normaliseSource(large, "image/png"),
|
||||
normaliseSource(small, "image/png"),
|
||||
]);
|
||||
expect(largePage.width).toBe(2200);
|
||||
expect(largePage.height).toBe(1320);
|
||||
expect(largePage.width).toBe(3200);
|
||||
expect(largePage.height).toBe(1920);
|
||||
expect(smallPage.width).toBe(80);
|
||||
expect(smallPage.height).toBe(120);
|
||||
});
|
||||
|
||||
it("keeps scan pixels and per-page quality warnings without rendering original attachments", async () => {
|
||||
const jpeg = await sharp({ create: { width: 800, height: 1600, channels: 3, background: "white" } }).jpeg().toBuffer();
|
||||
const pdf = await scansToPdf([{ jpeg, original: jpeg, originalName: "original.jpg", originalMime: "image/jpeg", qualityOverride: "Paper too faint to retake" }]);
|
||||
const pages = await normaliseSource(Buffer.from(pdf), "application/pdf");
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]).toMatchObject({ width: 800, height: 1600, captureReview: "Paper too faint to retake" });
|
||||
});
|
||||
|
||||
it("applies EXIF orientation and reports the applied rotation", async () => {
|
||||
const jpeg = await sharp({
|
||||
create: { width: 80, height: 120, channels: 3, background: "white" },
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { join } from "node:path";
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import sharp, { type Metadata } from "sharp";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { parseScanMetadata, type ScanMetadata } from "../scan-metadata";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const MAX_DIMENSION = 2200;
|
||||
const MAX_DIMENSION = 3200;
|
||||
const PDF_MAGIC = Buffer.from("%PDF-");
|
||||
|
||||
export interface NormalisedPage {
|
||||
|
|
@ -16,6 +18,8 @@ export interface NormalisedPage {
|
|||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
/** Explicit photographer override, retained for an answerable site review. */
|
||||
captureReview?: string;
|
||||
}
|
||||
|
||||
function isPdf(buffer: Buffer): boolean {
|
||||
|
|
@ -87,13 +91,23 @@ async function renderPdf(buffer: Buffer): Promise<NormalisedPage[]> {
|
|||
try {
|
||||
const path = join(dir, "in.pdf");
|
||||
await writeFile(path, buffer);
|
||||
await run("pdftoppm", ["-r", "150", "-png", path, join(dir, "pg")]);
|
||||
let scan: ScanMetadata | null = null;
|
||||
try {
|
||||
const pdf = await PDFDocument.load(buffer, { updateMetadata: false });
|
||||
const metadata = parseScanMetadata(pdf.getSubject());
|
||||
if (metadata?.pages.length === pdf.getPageCount()) scan = metadata;
|
||||
} catch { /* Metadata is optional; Poppler can render PDFs this parser cannot read. */ }
|
||||
await run("pdftoppm", ["-r", scan ? "300" : "150", "-png", path, join(dir, "pg")]);
|
||||
const files = (await readdir(dir))
|
||||
.filter((file) => /^pg-\d+\.png$/.test(file))
|
||||
.sort((a, b) => Number(a.match(/\d+/)?.[0]) - Number(b.match(/\d+/)?.[0]));
|
||||
if (files.length === 0) throw new Error("pdftoppm produced no pages");
|
||||
return Promise.all(
|
||||
files.map((file, index) => readFile(join(dir, file)).then((page) => rasterise(page, index + 1))),
|
||||
files.map(async (file, index) => {
|
||||
const page = await rasterise(await readFile(join(dir, file)), index + 1);
|
||||
const captureReview = scan?.pages[index]?.qualityOverride;
|
||||
return captureReview ? { ...page, captureReview } : page;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "unsupported_image_codec") throw error;
|
||||
|
|
|
|||
|
|
@ -154,6 +154,43 @@ beforeEach(() => {
|
|||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("runStagedBatch", () => {
|
||||
it("turns a poor-scan override into an answerable review question and keeps its answer on rerun", async () => {
|
||||
const fixture = stagedPb();
|
||||
normaliseSourceMock.mockResolvedValue([{ sourcePage: 1, png: Buffer.from("scan"), mime: "image/png", width: 800, height: 1600, rotation: 0, captureReview: "Original paper is faint" }]);
|
||||
extractPageMock.mockResolvedValue(read(1, "DN-SCAN", "PO-1"));
|
||||
await runStagedBatch(fixture.pb as never, BATCH_ID);
|
||||
const candidate = [...fixture.candidates.values()][0];
|
||||
const question = (candidate.checks as Array<{ id: string; passed: boolean; detail?: { source?: string } }>).find(check => check.id === "site_decision" && check.detail?.source === "clock-scan");
|
||||
expect(question).toMatchObject({ passed: false });
|
||||
expect(candidate.stage).toBe("needs_review");
|
||||
question!.passed = true;
|
||||
await runStagedBatch(fixture.pb as never, BATCH_ID);
|
||||
const decisions = (candidate.checks as Array<{ id: string; passed: boolean }>).filter(check => check.id === "site_decision");
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0].passed).toBe(true);
|
||||
});
|
||||
it("asks about a newly recovered poor page even when the first page warning was answered", async () => {
|
||||
const fixture = stagedPb();
|
||||
const firstImage = { sourcePage: 1, png: Buffer.from("scan1"), mime: "image/png", width: 800, height: 1600, rotation: 0, captureReview: "Faint paper" };
|
||||
normaliseSourceMock.mockResolvedValue([firstImage]);
|
||||
extractPageMock.mockImplementation(async (_image: Buffer, page: number) => ({ ...read(page, "DN-SCAN", "PO-1"), printed_page_number: page, printed_page_count: 2, is_continuation: page === 2 }));
|
||||
await runStagedBatch(fixture.pb as never, BATCH_ID);
|
||||
const candidate = [...fixture.candidates.values()][0];
|
||||
const firstQuestion = (candidate.checks as Array<{ id: string; passed: boolean }>).find(check => check.id === "site_decision")!;
|
||||
firstQuestion.passed = true;
|
||||
normaliseSourceMock.mockResolvedValue([firstImage, { ...firstImage, sourcePage: 2, png: Buffer.from("scan2"), captureReview: "Cannot retake page 2" }]);
|
||||
await runStagedBatch(fixture.pb as never, BATCH_ID);
|
||||
const questions = (candidate.checks as Array<{ id: string; passed: boolean; detail?: { pages?: number[] } }>).filter(check => check.id === "site_decision");
|
||||
expect(questions.some(check => check.passed === true)).toBe(true);
|
||||
expect(questions.some(check => check.passed === false && check.detail?.pages?.includes(2))).toBe(true);
|
||||
// The answer API replaces earlier site questions with the latest answer.
|
||||
// Its accumulated source-page coverage must still settle the first page.
|
||||
const latest = questions.at(-1)!;
|
||||
latest.passed = true;
|
||||
candidate.checks = [...(candidate.checks as Array<{ id: string }>).filter(check => check.id !== "site_decision"), latest];
|
||||
await runStagedBatch(fixture.pb as never, BATCH_ID);
|
||||
expect((candidate.checks as Array<{ id: string; passed: boolean }>).filter(check => check.id === "site_decision")).toEqual([expect.objectContaining({ passed: true })]);
|
||||
});
|
||||
it.each(["product_matched", "units_convertible"])("never treats a null required check as ready: %s", (id) => {
|
||||
expect(evaluateRequiredChecks([
|
||||
...["po_resolved", "supplier_matches_po", "product_matched", "units_convertible", "not_duplicate"].map((checkId) => ({ id: checkId, passed: checkId === id ? null : true })),
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ async function runClaimedStagedBatch(pb: PocketBase, batchId: string, ownership:
|
|||
const reads: PageRead[] = [];
|
||||
const failedPages: Array<{ page: number; reason: string }> = [];
|
||||
const pageDeliverySessionIds: Record<number, string> = {};
|
||||
const captureReviews = new Map<number, string>();
|
||||
const captureReviewKeys = new Map<number, string>();
|
||||
const persistedPages = new Map<number, PersistedPage>();
|
||||
let pageCount = 0;
|
||||
let pageOffset = 0;
|
||||
|
|
@ -116,6 +118,7 @@ async function runClaimedStagedBatch(pb: PocketBase, batchId: string, ownership:
|
|||
for (const image of normalised) {
|
||||
assertStagedDeadline(options.deadlineAt);
|
||||
const page = pageOffset + image.sourcePage;
|
||||
if (image.captureReview) { captureReviews.set(page, image.captureReview); captureReviewKeys.set(page, `${source.id}:${image.sourcePage}`); }
|
||||
pageCount += 1;
|
||||
if (source.delivery_session_id) pageDeliverySessionIds[page] = source.delivery_session_id;
|
||||
try {
|
||||
|
|
@ -259,6 +262,18 @@ async function runClaimedStagedBatch(pb: PocketBase, batchId: string, ownership:
|
|||
supplierGuardOverride: supplierGuardOverride(matched?.manual_overrides),
|
||||
};
|
||||
const noteChecks = runNoteChecks(note, checkContext);
|
||||
const poorPages = group.pages.filter(page => captureReviews.has(page));
|
||||
const coveredSourcePages = new Set<string>(siteDecisionChecks(matched?.checks).flatMap(check => {
|
||||
const detail = check.detail as { source?: string; sourcePages?: unknown } | undefined;
|
||||
return detail?.source === "clock-scan" && Array.isArray(detail.sourcePages) ? detail.sourcePages.filter((key): key is string => typeof key === "string") : [];
|
||||
}));
|
||||
const newPoorPages = poorPages.filter(page => !coveredSourcePages.has(captureReviewKeys.get(page)!));
|
||||
if (newPoorPages.length) {
|
||||
const pendingQuestion = openSiteDecision(matched?.checks)?.question;
|
||||
const warning = `This scan was sent with a photo-quality warning: ${newPoorPages.map(page => `page ${page}: ${captureReviews.get(page)}`).join("; ")}. Please compare the scan and original attachment, confirm the supplier, document reference and delivered quantities, or upload a clearer copy.`;
|
||||
const question = pendingQuestion ? `${pendingQuestion}\nAlso check the newly added scan evidence: ${warning}` : warning;
|
||||
noteChecks.push({ id: "site_decision", passed: false, reason: question, detail: { source: "clock-scan", question, askedAt: new Date().toISOString(), askedBy: "Clock scanner", pages: newPoorPages, sourcePages: [...new Set([...coveredSourcePages, ...poorPages.map(page => captureReviewKeys.get(page)!)])] } });
|
||||
}
|
||||
if (proposalNeedsReview) noteChecks.push({id:"page_grouping_confirmed",passed:false,reason:"Confirm this batch’s proposed grouping before processing or posting its documents."});
|
||||
if (poReviewQuestion) { const check = noteChecks.find(check=>check.id==="po_resolved"); if (check && check.passed===false) check.reason=poReviewQuestion; }
|
||||
if (note.doc_type === "unknown") noteChecks.push({id:"document_type",passed:false,
|
||||
|
|
@ -658,7 +673,7 @@ async function upsertCandidate(
|
|||
purchase_order: resolution.purchaseOrderId ?? "",
|
||||
supplier: resolution.supplierId ?? "",
|
||||
items: noteItems,
|
||||
checks: [...checks, ...siteDecisions],
|
||||
checks: [...checks.filter(check => check.id !== "site_decision"), ...siteDecisions.filter(check => !checks.some(next => next.id === "site_decision") || check.passed === true), ...checks.filter(check => check.id === "site_decision")],
|
||||
...(checks.some(check=>check.id === "page_grouping_confirmed" && check.passed===false) ? { reconciliation_preview: null } : {}),
|
||||
assembly_revision: assemblyRevision,
|
||||
match_method: resolution.method,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { detectPaperCorners, rectifyDocument, type PaperCorners } from "./document-crop";
|
||||
import { ANALYSIS_WIDTH, assessGray, toGrayscale } from "./image-quality";
|
||||
import { assessDocumentScan, documentBodyQuality, enhanceDocumentPixels } from "./document-scan-quality";
|
||||
|
||||
function canvas(width: number, height: number) {
|
||||
const result = document.createElement("canvas");
|
||||
|
|
@ -32,6 +33,23 @@ export function cropDocumentPhoto(source: HTMLCanvasElement, corners: PaperCorne
|
|||
target.putImageData(pixels, 0, 0);
|
||||
return output;
|
||||
}
|
||||
export function inspectDocumentPhoto(source: HTMLCanvasElement, corners: PaperCorners | null, cropped = source) {
|
||||
const work = canvas(ANALYSIS_WIDTH, Math.max(2, Math.round(cropped.height * ANALYSIS_WIDTH / cropped.width)));
|
||||
const ctx = work.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not check the document. Try again.");
|
||||
ctx.drawImage(cropped, 0, 0, work.width, work.height);
|
||||
const gray = toGrayscale(ctx.getImageData(0, 0, work.width, work.height).data, work.width, work.height);
|
||||
return assessDocumentScan({ report: assessGray(gray, work.width, work.height), corners, width: source.width, height: source.height, body: documentBodyQuality(gray, work.width, work.height) });
|
||||
}
|
||||
export function enhanceDocumentPhoto(source: HTMLCanvasElement) {
|
||||
const ctx = source.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not enhance the document. Try again.");
|
||||
const pixels = ctx.getImageData(0, 0, source.width, source.height);
|
||||
pixels.data.set(enhanceDocumentPixels(pixels.data, source.width, source.height));
|
||||
const output = canvas(source.width, source.height);
|
||||
output.getContext("2d")!.putImageData(pixels, 0, 0);
|
||||
return output;
|
||||
}
|
||||
export function photoBlob(source: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => source.toBlob(blob => {
|
||||
if (blob) resolve(blob); else reject(new Error("Could not capture the photo — try again."));
|
||||
|
|
|
|||
46
src/lib/document-scan-quality.test.ts
Normal file
46
src/lib/document-scan-quality.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { assessDocumentScan, documentBodyQuality, enhanceDocumentPixels } from "./document-scan-quality";
|
||||
const report = { sharpness: 175, brightness: 117, verdict: "good" as const, message: "Looks sharp" };
|
||||
const corners = [{ x: .05, y: .05 }, { x: .95, y: .05 }, { x: .95, y: .95 }, { x: .05, y: .95 }] as const;
|
||||
describe("document-specific scan quality", () => {
|
||||
it("does not call a sharp desk scene readable when no paper was found", () => {
|
||||
const result = assessDocumentScan({ report, corners: null, width: 1440, height: 2560 });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues).toContain("edges");
|
||||
});
|
||||
it("asks the photographer to move closer when a small document occupies a large frame", () => {
|
||||
const result = assessDocumentScan({ report, width: 3000, height: 4000, corners: corners.map(p => ({ x: p.x * .4, y: p.y * .4 })) });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues).toContain("distance");
|
||||
});
|
||||
it("rejects too few document pixels even in a tightly framed image", () => {
|
||||
expect(assessDocumentScan({ report, corners, width: 500, height: 750 }).issues).toContain("resolution");
|
||||
});
|
||||
it("permits a sufficiently large sharp document", () => {
|
||||
expect(assessDocumentScan({ report, corners, width: 2200, height: 3000, body: { sharpness: 200, contrast: 90 } }).ok).toBe(true);
|
||||
});
|
||||
it("does not let a sharp logo conceal a faded document body", () => {
|
||||
const result = assessDocumentScan({ report, corners, width: 2200, height: 3000, body: { sharpness: 10, contrast: 8 } });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues).toContain("faint");
|
||||
});
|
||||
it("measures the body without treating a dark border as text", () => {
|
||||
const gray = new Uint8ClampedArray(100 * 100).fill(240);
|
||||
gray.fill(0, 0, 1000);
|
||||
expect(documentBodyQuality(gray, 100, 100).contrast).toBe(0);
|
||||
});
|
||||
});
|
||||
describe("conservative print enhancement", () => {
|
||||
it("lifts shaded paper while preserving relative faint marks and alpha", () => {
|
||||
const pixels = new Uint8ClampedArray(64 * 64 * 4);
|
||||
for (let i = 0; i < pixels.length; i += 4) pixels.set([180, 180, 180, 255], i);
|
||||
pixels.set([150, 150, 150, 255], (32 * 64 + 32) * 4);
|
||||
const result = enhanceDocumentPixels(pixels, 64, 64);
|
||||
expect(result[0]).toBeGreaterThan(180);
|
||||
const mark = (32 * 64 + 32) * 4;
|
||||
expect(result[mark]).toBeLessThan(result[0] - 20);
|
||||
expect(result[mark]).toBeGreaterThan(0);
|
||||
expect(result[mark + 3]).toBe(255);
|
||||
expect(pixels[0]).toBe(180);
|
||||
});
|
||||
});
|
||||
76
src/lib/document-scan-quality.ts
Normal file
76
src/lib/document-scan-quality.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { laplacianVariance, type QualityReport } from "./image-quality";
|
||||
import type { PaperPoint } from "./document-crop";
|
||||
|
||||
export interface ScanQuality { ok: boolean; message: string; issues: string[] }
|
||||
export interface BodyQuality { sharpness: number; contrast: number }
|
||||
|
||||
/** Never interpret sharp background furniture as proof of readable paper. */
|
||||
export function assessDocumentScan({ report, corners, width, height, body }: {
|
||||
report: QualityReport; corners: readonly PaperPoint[] | null; width: number; height: number; body?: BodyQuality;
|
||||
}): ScanQuality {
|
||||
const issues: string[] = [];
|
||||
const messages: string[] = [];
|
||||
const add = (id: string, message: string) => { issues.push(id); messages.push(message); };
|
||||
if (!corners || corners.length !== 4) return { ok: false, issues: ["edges"], message: "Find the paper edges, or set them after taking the photo." };
|
||||
const area = Math.abs(corners.reduce((sum, p, i) => { const q = corners[(i + 1) % 4]; return sum + p.x * q.y - q.x * p.y; }, 0)) / 2;
|
||||
if (area < .45) add("distance", "Move closer so the paper fills the picture.");
|
||||
const edge = (a: number, b: number) => Math.hypot((corners[a].x - corners[b].x) * width, (corners[a].y - corners[b].y) * height);
|
||||
const paperWidth = Math.min(edge(0, 1), edge(3, 2));
|
||||
const paperHeight = Math.min(edge(0, 3), edge(1, 2));
|
||||
if (Math.min(paperWidth, paperHeight) < 700 || Math.max(paperWidth, paperHeight) < 1400) add("resolution", "The print may be too small. Take a closer photo.");
|
||||
if (report.verdict === "dark") add("dark", "Find more even light and move out of your shadow.");
|
||||
if (report.verdict === "blurred" || (body && body.sharpness < 45)) add("blur", "Hold still and let the camera focus on the print.");
|
||||
if (body && body.contrast < 22) add("faint", "The print looks faint. Check it closely or try better light.");
|
||||
return { ok: issues.length === 0, issues, message: messages[0] ?? "Check the heading, quantities and totals before using this scan." };
|
||||
}
|
||||
|
||||
/** Ignore headers and page borders; assess the body where the small print lives. */
|
||||
export function documentBodyQuality(gray: Uint8ClampedArray, width: number, height: number): BodyQuality {
|
||||
const left = Math.floor(width * .12), top = Math.floor(height * .28);
|
||||
const w = Math.max(1, Math.floor(width * .76)), h = Math.max(1, Math.floor(height * .48));
|
||||
const body = new Uint8ClampedArray(w * h);
|
||||
const histogram = new Uint32Array(256);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const value = gray[(top + y) * width + left + x];
|
||||
body[y * w + x] = value; histogram[value]++;
|
||||
}
|
||||
const percentile = (fraction: number) => {
|
||||
let count = 0;
|
||||
for (let i = 0; i < 256; i++) { count += histogram[i]; if (count >= body.length * fraction) return i; }
|
||||
return 255;
|
||||
};
|
||||
return { sharpness: laplacianVariance(body, w, h), contrast: percentile(.95) - percentile(.05) };
|
||||
}
|
||||
|
||||
/** Gentle grayscale illumination correction, not binary thresholding or reconstruction. */
|
||||
export function enhanceDocumentPixels(rgba: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray {
|
||||
if (rgba.length !== width * height * 4 || !width || !height) throw new Error("Invalid document image");
|
||||
const step = 32, cols = Math.ceil(width / step), rows = Math.ceil(height / step);
|
||||
const sums = new Float64Array(cols * rows), counts = new Uint32Array(cols * rows);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4, cell = Math.floor(y / step) * cols + Math.floor(x / step);
|
||||
sums[cell] += (rgba[i] * 299 + rgba[i + 1] * 587 + rgba[i + 2] * 114) / 1000; counts[cell]++;
|
||||
}
|
||||
const levels = new Float32Array(cols * rows);
|
||||
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
|
||||
let sum = 0, count = 0;
|
||||
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
||||
if (x + dx < 0 || x + dx >= cols || y + dy < 0 || y + dy >= rows) continue;
|
||||
const cell = (y + dy) * cols + x + dx; sum += sums[cell]; count += counts[cell];
|
||||
}
|
||||
levels[y * cols + x] = Math.max(120, sum / count);
|
||||
}
|
||||
const output = new Uint8ClampedArray(rgba.length);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
// Interpolate the illumination grid to avoid artificial tile boundaries.
|
||||
const gx = Math.max(0, Math.min(cols - 1, x / step - .5)), gy = Math.max(0, Math.min(rows - 1, y / step - .5));
|
||||
const x0 = Math.floor(gx), y0 = Math.floor(gy), x1 = Math.min(cols - 1, x0 + 1), y1 = Math.min(rows - 1, y0 + 1);
|
||||
const fx = gx - x0, fy = gy - y0;
|
||||
const background = (levels[y0 * cols + x0] * (1 - fx) + levels[y0 * cols + x1] * fx) * (1 - fy) + (levels[y1 * cols + x0] * (1 - fx) + levels[y1 * cols + x1] * fx) * fy;
|
||||
const gray = (rgba[i] * 299 + rgba[i + 1] * 587 + rgba[i + 2] * 114) / 1000;
|
||||
const value = (gray * 245 / background - 240) * 1.15 + 240;
|
||||
output[i] = output[i + 1] = output[i + 2] = value; output[i + 3] = rgba[i + 3];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
11
src/lib/scan-metadata.ts
Normal file
11
src/lib/scan-metadata.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const SCAN_METADATA_PREFIX = "MEGA_SCAN_V1:";
|
||||
export interface ScanMetadata { version: 1; pages: { qualityOverride: string }[] }
|
||||
export function parseScanMetadata(subject?: string): ScanMetadata | null {
|
||||
if (!subject?.startsWith(SCAN_METADATA_PREFIX) || subject.length > 12000) return null;
|
||||
try {
|
||||
const value = JSON.parse(subject.slice(SCAN_METADATA_PREFIX.length));
|
||||
if (value.version !== 1 || !Array.isArray(value.pages) || !value.pages.length || value.pages.length > 12 ||
|
||||
value.pages.some((page: { qualityOverride?: unknown } | null) => !page || typeof page.qualityOverride !== "string" || page.qualityOverride.length > 500)) return null;
|
||||
return { version: 1, pages: value.pages.map((page: { qualityOverride: string }) => ({ qualityOverride: page.qualityOverride })) };
|
||||
} catch { return null; }
|
||||
}
|
||||
29
src/lib/scan-pdf.test.ts
Normal file
29
src/lib/scan-pdf.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRawStream, decodePDFRawStream } from "pdf-lib";
|
||||
import { scansToPdf } from "./scan-pdf";
|
||||
import { parseScanMetadata } from "./scan-metadata";
|
||||
|
||||
describe("scan evidence PDF", () => {
|
||||
it("keeps exact original bytes as attachments without adding OCR pages", async () => {
|
||||
const jpeg = await sharp({ create: { width: 60, height: 90, channels: 3, background: "white" } }).jpeg().toBuffer();
|
||||
const original = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const bytes = await scansToPdf([{ jpeg, original, originalName: "phone.jpg", originalMime: "image/jpeg", qualityOverride: "Original paper is faint" }]);
|
||||
const doc = await PDFDocument.load(bytes);
|
||||
expect(doc.getPageCount()).toBe(1);
|
||||
const names = doc.catalog.lookup(PDFName.of("Names"), PDFDict).lookup(PDFName.of("EmbeddedFiles"), PDFDict).lookup(PDFName.of("Names"), PDFArray);
|
||||
const spec = names.lookup(1, PDFDict);
|
||||
const stream = spec.lookup(PDFName.of("EF"), PDFDict).lookup(PDFName.of("F")) as PDFRawStream;
|
||||
expect(decodePDFRawStream(stream).decode()).toEqual(original);
|
||||
expect(parseScanMetadata(doc.getSubject())?.pages[0].qualityOverride).toBe("Original paper is faint");
|
||||
});
|
||||
it("produces identical bytes on retry so file hash dedup still works", async () => {
|
||||
const jpeg = await sharp({ create: { width: 20, height: 30, channels: 3, background: "white" } }).jpeg().toBuffer();
|
||||
const pages = [{ jpeg, original: jpeg, originalName: "photo.jpg", originalMime: "image/jpeg", qualityOverride: "" }];
|
||||
expect(await scansToPdf(pages)).toEqual(await scansToPdf(pages));
|
||||
});
|
||||
it("ignores unrelated or malformed PDF metadata", () => {
|
||||
expect(parseScanMetadata("Some supplier's document")).toBeNull();
|
||||
expect(parseScanMetadata('MEGA_SCAN_V1:{"pages":[{"qualityOverride":42}]}')).toBeNull();
|
||||
});
|
||||
});
|
||||
25
src/lib/scan-pdf.ts
Normal file
25
src/lib/scan-pdf.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { SCAN_METADATA_PREFIX } from "./scan-metadata";
|
||||
export interface ScanPdfPage {
|
||||
jpeg: Uint8Array; original: Uint8Array; originalName: string; originalMime: string; qualityOverride: string;
|
||||
}
|
||||
|
||||
/** Original files are attachments, never extra pages for OCR or receiving. */
|
||||
export async function scansToPdf(pages: readonly ScanPdfPage[]): Promise<Uint8Array> {
|
||||
if (!pages.length || pages.length > 12) throw new Error("Choose between 1 and 12 scan pages");
|
||||
const { PDFDocument } = await import("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
// Stable metadata makes a retry of unchanged content the same file/hash.
|
||||
const epoch = new Date("2026-01-01T00:00:00Z");
|
||||
doc.setCreationDate(epoch); doc.setModificationDate(epoch);
|
||||
doc.setProducer("MEGA Clock document scanner"); doc.setCreator("MEGA Clock");
|
||||
doc.setSubject(SCAN_METADATA_PREFIX + JSON.stringify({ version: 1, pages: pages.map(p => ({ qualityOverride: p.qualityOverride.slice(0, 500) })) }));
|
||||
for (const [index, page] of pages.entries()) {
|
||||
const image = await doc.embedJpg(page.jpeg);
|
||||
// 300 DPI paper coordinates avoid oversampling image pixels during rendering.
|
||||
const visible = doc.addPage([image.width * 72 / 300, image.height * 72 / 300]);
|
||||
visible.drawImage(image, { x: 0, y: 0, width: visible.getWidth(), height: visible.getHeight() });
|
||||
const safeName = page.originalName.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-100) || "photo.jpg";
|
||||
await doc.attach(page.original, `original-${index + 1}-${safeName}`, { mimeType: page.originalMime, description: `Unedited original photo for scan page ${index + 1}`, creationDate: epoch, modificationDate: epoch });
|
||||
}
|
||||
return doc.save();
|
||||
}
|
||||
Loading…
Reference in a new issue