From 9a1095ad6e75b661329e855d20680e7582d415ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 4 May 2026 21:39:32 +0200 Subject: [PATCH] feat: implement region-based QR code scanning for inbox documents --- .../src/barcode/barcode-scanner.service.ts | 57 ++++++++ .../entities/inbox-document.entity.ts | 3 + .../src/inbox/inbox.controller.ts | 11 ++ paperless-backend/src/inbox/inbox.service.ts | 17 ++- .../src/scanner/scanner-watcher.service.ts | 1 + paperless-frontend/src/api/inbox.ts | 8 ++ .../src/pages/InboxDetailPage.tsx | 132 +++++++++++++++++- 7 files changed, 226 insertions(+), 3 deletions(-) diff --git a/paperless-backend/src/barcode/barcode-scanner.service.ts b/paperless-backend/src/barcode/barcode-scanner.service.ts index 07bea50..cbc627d 100644 --- a/paperless-backend/src/barcode/barcode-scanner.service.ts +++ b/paperless-backend/src/barcode/barcode-scanner.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs/promises'; +import sharp = require('sharp'); import { PdfService } from '../preprocessing/pdf.service'; import { QrCodeService } from '../preprocessing/qr-code.service'; import { @@ -76,6 +77,7 @@ export class BarcodeScannerService implements OnApplicationBootstrap { doc.QrCodes = qrCodes; doc.PageCount = pageCount; + doc.IsScanned = true; try { await this.documentRepo.save(doc); } catch (err: any) { @@ -174,6 +176,61 @@ export class BarcodeScannerService implements OnApplicationBootstrap { } } + /** + * Rendert eine einzelne Seite bei hoher DPI, beschneidet den angegebenen + * Bereich (normalisierte Koordinaten 0..1) und scannt ihn nach QR-Codes. + * Neu gefundene QR-Codes werden in der DB persistiert. + */ + async scanRegion( + doc: InboxDocument, + pdfPath: string, + page: number, + x: number, + y: number, + w: number, + h: number, + ): Promise<{ found: string[] }> { + let imagePath: string | null = null; + try { + imagePath = await this.pdfService.pdfPageToImage(pdfPath, page, 400); + + const image = sharp(imagePath); + const { width: imgW, height: imgH } = await image.metadata(); + if (!imgW || !imgH) return { found: [] }; + + const left = Math.round(Math.max(0, x * imgW)); + const top = Math.round(Math.max(0, y * imgH)); + const width = Math.round(Math.min(imgW - left, w * imgW)); + const height = Math.round(Math.min(imgH - top, h * imgH)); + if (width <= 0 || height <= 0) return { found: [] }; + + const cropped = await image.extract({ left, top, width, height }).png().toBuffer(); + const qrResults = await this.qrCodeService.extractFromImage(cropped); + if (qrResults.length === 0) return { found: [] }; + + const existingKeys = new Set((doc.QrCodes ?? []).map((qr) => `${qr.page}:${qr.value}`)); + const found: string[] = []; + let changed = false; + + for (const qr of qrResults) { + found.push(qr.data); + const key = `${page}:${qr.data}`; + if (!existingKeys.has(key)) { + doc.QrCodes = [...(doc.QrCodes ?? []), { page, value: qr.data }]; + changed = true; + } + } + + if (changed) { + await this.documentRepo.save(doc); + } + + return { found }; + } finally { + if (imagePath) await this.pdfService.cleanup([imagePath]); + } + } + /** * Rescannt alle Inbox-Dokumente — wird nach Änderungen an Eingangsdokumentarten aufgerufen. * Läuft sequenziell, um PDF-Rendering nicht zu überlasten. Fire-and-forget vom Caller. diff --git a/paperless-backend/src/database/entities/inbox-document.entity.ts b/paperless-backend/src/database/entities/inbox-document.entity.ts index cfc3ddd..5de7692 100644 --- a/paperless-backend/src/database/entities/inbox-document.entity.ts +++ b/paperless-backend/src/database/entities/inbox-document.entity.ts @@ -32,6 +32,9 @@ export class InboxDocument { @Column({ type: 'int', default: 0 }) PageCount!: number; + @Column({ type: 'boolean', default: true }) + IsScanned!: boolean; + @Column({ type: 'json' }) QrCodes!: StoredQrCode[]; diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index 5a762bc..256e912 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -143,6 +143,17 @@ export class InboxController { return new StreamableFile(createReadStream(filePath)); } + @Post(':id/pages/:page/scan-region') + async scanRegion( + @Param('id') id: string, + @Param('page', ParseIntPipe) page: number, + @Body() body: { x: number; y: number; w: number; h: number }, + @Request() req: any, + ): Promise<{ found: string[] }> { + const preferredUsername: string | null = req.user?.preferredUsername ?? null; + return this.inboxService.scanRegion(id, page, body.x, body.y, body.w, body.h, preferredUsername); + } + @Post(':id/source') @HttpCode(204) async updateSource( diff --git a/paperless-backend/src/inbox/inbox.service.ts b/paperless-backend/src/inbox/inbox.service.ts index 946e1e5..a917b35 100644 --- a/paperless-backend/src/inbox/inbox.service.ts +++ b/paperless-backend/src/inbox/inbox.service.ts @@ -44,8 +44,8 @@ export class InboxService { async listFiles(preferredUsername: string | null): Promise { const where = preferredUsername - ? [{ Source: 'all' as InboxSource }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername }] - : [{ Source: 'all' as InboxSource }]; + ? [{ Source: 'all' as InboxSource, IsScanned: true }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername, IsScanned: true }] + : [{ Source: 'all' as InboxSource, IsScanned: true }]; const docs = await this.documentRepo.find({ where, @@ -212,4 +212,17 @@ export class InboxService { await this.documentRepo.save(doc); } + + async scanRegion( + id: string, + page: number, + x: number, + y: number, + w: number, + h: number, + preferredUsername: string | null, + ): Promise<{ found: string[] }> { + const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); + return this.barcodeScanner.scanRegion(doc, pdfPath, page, x, y, w, h); + } } diff --git a/paperless-backend/src/scanner/scanner-watcher.service.ts b/paperless-backend/src/scanner/scanner-watcher.service.ts index 4f4c7b7..9f296b3 100644 --- a/paperless-backend/src/scanner/scanner-watcher.service.ts +++ b/paperless-backend/src/scanner/scanner-watcher.service.ts @@ -195,6 +195,7 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { OwnerUsername: owner, PageCount: 0, QrCodes: [], + IsScanned: false, }); await this.documentRepo.save(doc); diff --git a/paperless-frontend/src/api/inbox.ts b/paperless-frontend/src/api/inbox.ts index 21abf92..5a86090 100644 --- a/paperless-frontend/src/api/inbox.ts +++ b/paperless-frontend/src/api/inbox.ts @@ -93,6 +93,14 @@ export const inboxApi = { api .post(`/api/inbox/${encodeURIComponent(id)}/source`, { source }) .then((r) => r.data), + + scanRegion: (id: string, page: number, region: { x: number; y: number; w: number; h: number }) => + api + .post<{ found: string[] }>( + `/api/inbox/${encodeURIComponent(id)}/pages/${page}/scan-region`, + region, + ) + .then((r) => r.data), }; export interface PostprocessActionResult { diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index 6649413..5610e24 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Button, Empty, Modal, Popconfirm, Space, Spin, Steps, Tag, Tooltip, Typography, message } from 'antd'; import { @@ -7,6 +7,7 @@ import { FolderOpenOutlined, LeftOutlined, LoadingOutlined, + QrcodeOutlined, RedoOutlined, RightOutlined, ThunderboltOutlined, @@ -508,9 +509,15 @@ export default function InboxDetailPage() { const [selectedPage, setSelectedPage] = useState(1); const [zoom, setZoom] = useState(1); const [wizardOpen, setWizardOpen] = useState(false); + const [scanMode, setScanMode] = useState(false); + const [scanning, setScanning] = useState(false); + const [dragStart, setDragStart] = useState<{ clientX: number; clientY: number; relX: number; relY: number } | null>(null); + const [dragEnd, setDragEnd] = useState<{ relX: number; relY: number } | null>(null); const thumbsRef = useRef>(new Map()); const previewRef = useRef(null); + const imgRef = useRef(null); + const overlayRef = useRef(null); const rotationFor = (page: number): number => file?.rotations?.[String(page)] ?? 0; @@ -686,6 +693,84 @@ export default function InboxDetailPage() { } }; + const toNormalizedImageCoords = useCallback((clientX: number, clientY: number) => { + const img = imgRef.current; + if (!img) return null; + const rect = img.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = clientX - cx; + const dy = clientY - cy; + const dxU = dx / zoom; + const dyU = dy / zoom; + const Rrad = currentRotation * Math.PI / 180; + const cosR = Math.cos(Rrad); + const sinR = Math.sin(Rrad); + const dxUr = dxU * cosR + dyU * sinR; + const dyUr = -dxU * sinR + dyU * cosR; + const rW = (currentRotation === 90 || currentRotation === 270) ? rect.height / zoom : rect.width / zoom; + const rH = (currentRotation === 90 || currentRotation === 270) ? rect.width / zoom : rect.height / zoom; + return { + x: Math.max(0, Math.min(1, (dxUr + rW / 2) / rW)), + y: Math.max(0, Math.min(1, (dyUr + rH / 2) / rH)), + }; + }, [zoom, currentRotation]); + + const handleOverlayMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const overlayRect = e.currentTarget.getBoundingClientRect(); + setDragStart({ clientX: e.clientX, clientY: e.clientY, relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top }); + setDragEnd(null); + }, []); + + const handleOverlayMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragStart) return; + const overlayRect = e.currentTarget.getBoundingClientRect(); + setDragEnd({ relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top }); + }, [dragStart]); + + const handleOverlayMouseUp = useCallback(async (e: React.MouseEvent) => { + if (!dragStart || !file) { setDragStart(null); setDragEnd(null); return; } + const overlayRect = e.currentTarget.getBoundingClientRect(); + const end = { clientX: e.clientX, clientY: e.clientY, relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top }; + setDragStart(null); + setDragEnd(null); + setScanMode(false); + + const p1 = toNormalizedImageCoords(dragStart.clientX, dragStart.clientY); + const p2 = toNormalizedImageCoords(end.clientX, end.clientY); + if (!p1 || !p2) return; + + const rx = Math.min(p1.x, p2.x); + const ry = Math.min(p1.y, p2.y); + const rw = Math.abs(p2.x - p1.x); + const rh = Math.abs(p2.y - p1.y); + if (rw < 0.01 || rh < 0.01) { message.warning('Bitte einen größeren Bereich auswählen'); return; } + + setScanning(true); + try { + const result = await inboxApi.scanRegion(file.id, selectedPage, { x: rx, y: ry, w: rw, h: rh }); + if (result.found.length > 0) { + message.success(`QR-Code gefunden: ${result.found.join(', ')}`); + const list = await inboxApi.list(); + const refreshed = list.find((f) => f.id === file.id) ?? null; + if (refreshed) setFile(refreshed); + } else { + message.warning('Kein QR-Code in diesem Bereich gefunden'); + } + } catch { + message.error('QR-Bereich-Scan fehlgeschlagen'); + } finally { + setScanning(false); + } + }, [dragStart, file, selectedPage, toNormalizedImageCoords]); + + const handleOverlayMouseLeave = useCallback(() => { + setDragStart(null); + setDragEnd(null); + setScanMode(false); + }, []); + useEffect(() => { if (!file) return; let cancelled = false; @@ -1085,6 +1170,19 @@ export default function InboxDetailPage() { /> )} + + +