From 87f966fb89b83a281e9f243fbe031d7ab47010ae Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 6 Nov 2026 07:03:06 +0200 Subject: [PATCH] feat(clock): use still photos with required document scan review --- .../plans/2026-11-06-clock-scan-quality.md | 67 ++ src/app/clock/document-crop-editor.tsx | 2 +- src/app/clock/document-scan-review.tsx | 55 ++ src/app/clock/document-scanner.test.tsx | 118 ++++ src/app/clock/document-scanner.tsx | 654 +++++------------- src/app/clock/documents-view.test.tsx | 2 +- src/app/clock/documents-view.tsx | 2 +- src/lib/dn-pipeline/normalise-source.test.ts | 27 +- src/lib/dn-pipeline/normalise-source.ts | 20 +- src/lib/dn-pipeline/run-staged.test.ts | 37 + src/lib/dn-pipeline/run-staged.ts | 17 +- src/lib/document-photo.ts | 18 + src/lib/document-scan-quality.test.ts | 46 ++ src/lib/document-scan-quality.ts | 76 ++ src/lib/scan-metadata.ts | 11 + src/lib/scan-pdf.test.ts | 29 + src/lib/scan-pdf.ts | 25 + 17 files changed, 701 insertions(+), 505 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-06-clock-scan-quality.md create mode 100644 src/app/clock/document-scan-review.tsx create mode 100644 src/app/clock/document-scanner.test.tsx create mode 100644 src/lib/document-scan-quality.test.ts create mode 100644 src/lib/document-scan-quality.ts create mode 100644 src/lib/scan-metadata.ts create mode 100644 src/lib/scan-pdf.test.ts create mode 100644 src/lib/scan-pdf.ts diff --git a/docs/superpowers/plans/2026-11-06-clock-scan-quality.md b/docs/superpowers/plans/2026-11-06-clock-scan-quality.md new file mode 100644 index 0000000..8a41a5a --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-clock-scan-quality.md @@ -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. diff --git a/src/app/clock/document-crop-editor.tsx b/src/app/clock/document-crop-editor.tsx index ac6c32a..32e3064 100644 --- a/src/app/clock/document-crop-editor.tsx +++ b/src/app/clock/document-crop-editor.tsx @@ -25,7 +25,7 @@ export function DocumentCropEditor({ url, initialCorners, busy, error, onApply, { if (!open && !busy) onClose(); }}> { if (busy) event.preventDefault(); }} onPointerDownOutside={event => { if (busy) event.preventDefault(); }}> - Adjust paper edges + Confirm paper edges Drag each corner onto the paper's corner. Include the heading and totals. You can also select a corner and use the arrow keys.
diff --git a/src/app/clock/document-scan-review.tsx b/src/app/clock/document-scan-review.tsx new file mode 100644 index 0000000..617a535 --- /dev/null +++ b/src/app/clock/document-scan-review.tsx @@ -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
+ +
+
+ + +
+
+ {([['colour', 'Colour'], ['print', 'Clearer print']] as const).map(([mode, label]) => )} +
+

{!page.cropConfirmed ? "Confirm all four paper edges before using this scan." : page.quality.message}

+ {page.cropConfirmed && !page.quality.ok && } +
+ + + Check the small printZoom and scroll to check the supplier, document number, quantities and totals. + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Document detail +
+
+
+
; +} diff --git a/src/app/clock/document-scanner.test.tsx b/src/app/clock/document-scanner.test.tsx new file mode 100644 index 0000000..f4936cf --- /dev/null +++ b/src/app/clock/document-scanner.test.tsx @@ -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(), 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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]); + }); +}); diff --git a/src/app/clock/document-scanner.tsx b/src/app/clock/document-scanner.tsx index 02729f7..7dbe105 100644 --- a/src/app/clock/document-scanner.tsx +++ b/src/app/clock/document-scanner.tsx @@ -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(null); - const [cropBusy, setCropBusy] = React.useState(false); - const cropBusyRef = React.useRef(false); - const [cropError, setCropError] = React.useState(null); - const fileInputRef = React.useRef(null); - const cameraInputRef = React.useRef(null); - const resetTimerRef = React.useRef | null>(null); - const [files, setFiles] = React.useState([]); - const [sentDuplicate, setSentDuplicate] = React.useState(false); const label = DOCUMENT_LABELS[kind]; - const color = kind === "delivery" ? "#22d3ee" : "#a78bfa"; - const videoRef = React.useRef(null); - const workRef = React.useRef(null); - const fullRef = React.useRef(null); - + const cameraInputRef = React.useRef(null); + const fileInputRef = React.useRef(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 | null>(null); + const pagesRef = React.useRef([]); + const [pages, setPages] = React.useState([]); const [phase, setPhase] = React.useState("camera"); - const [cameraError, setCameraError] = React.useState(null); - const [live, setLive] = React.useState(null); - const [pages, setPages] = React.useState([]); - const [reviewIndex, setReviewIndex] = React.useState(null); - const [retakeIndex, setRetakeIndex] = React.useState(null); - const [siteId, setSiteId] = React.useState(() => initialSiteId || (sites.length === 1 ? sites[0].id : "")); - const [siteHint, setSiteHint] = React.useState(() => 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(null); + const [selectedId, setSelectedId] = React.useState(null); + const [retakeId, setRetakeId] = React.useState(null); + const [editingId, setEditingId] = React.useState(null); + const [photoQueue, setPhotoQueue] = React.useState([]); + const [queueFailed, setQueueFailed] = React.useState(false); + const [pdfFiles, setPdfFiles] = React.useState([]); + 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) { 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([]); - 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 | 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) { - 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) { - 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 ( -
-
diff --git a/src/lib/dn-pipeline/normalise-source.test.ts b/src/lib/dn-pipeline/normalise-source.test.ts index 390e243..fd820af 100644 --- a/src/lib/dn-pipeline/normalise-source.test.ts +++ b/src/lib/dn-pipeline/normalise-source.test.ts @@ -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((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" }, diff --git a/src/lib/dn-pipeline/normalise-source.ts b/src/lib/dn-pipeline/normalise-source.ts index c0ea830..08432a8 100644 --- a/src/lib/dn-pipeline/normalise-source.ts +++ b/src/lib/dn-pipeline/normalise-source.ts @@ -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 { 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; diff --git a/src/lib/dn-pipeline/run-staged.test.ts b/src/lib/dn-pipeline/run-staged.test.ts index 3890ccf..853a04e 100644 --- a/src/lib/dn-pipeline/run-staged.test.ts +++ b/src/lib/dn-pipeline/run-staged.test.ts @@ -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 })), diff --git a/src/lib/dn-pipeline/run-staged.ts b/src/lib/dn-pipeline/run-staged.ts index 8f59a10..4df8241 100644 --- a/src/lib/dn-pipeline/run-staged.ts +++ b/src/lib/dn-pipeline/run-staged.ts @@ -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 = {}; + const captureReviews = new Map(); + const captureReviewKeys = new Map(); const persistedPages = new Map(); 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(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, diff --git a/src/lib/document-photo.ts b/src/lib/document-photo.ts index 13344ba..c46e1dd 100644 --- a/src/lib/document-photo.ts +++ b/src/lib/document-photo.ts @@ -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 { return new Promise((resolve, reject) => source.toBlob(blob => { if (blob) resolve(blob); else reject(new Error("Could not capture the photo — try again.")); diff --git a/src/lib/document-scan-quality.test.ts b/src/lib/document-scan-quality.test.ts new file mode 100644 index 0000000..db87939 --- /dev/null +++ b/src/lib/document-scan-quality.test.ts @@ -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); + }); +}); diff --git a/src/lib/document-scan-quality.ts b/src/lib/document-scan-quality.ts new file mode 100644 index 0000000..885128b --- /dev/null +++ b/src/lib/document-scan-quality.ts @@ -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; +} diff --git a/src/lib/scan-metadata.ts b/src/lib/scan-metadata.ts new file mode 100644 index 0000000..59df8d8 --- /dev/null +++ b/src/lib/scan-metadata.ts @@ -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; } +} diff --git a/src/lib/scan-pdf.test.ts b/src/lib/scan-pdf.test.ts new file mode 100644 index 0000000..eb22081 --- /dev/null +++ b/src/lib/scan-pdf.test.ts @@ -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(); + }); +}); diff --git a/src/lib/scan-pdf.ts b/src/lib/scan-pdf.ts new file mode 100644 index 0000000..89c4c97 --- /dev/null +++ b/src/lib/scan-pdf.ts @@ -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 { + 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(); +}