feat: implement region-based QR code scanning for inbox documents
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s

This commit is contained in:
2026-05-04 21:39:32 +02:00
parent 60ac522435
commit 9a1095ad6e
7 changed files with 226 additions and 3 deletions
@@ -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.
@@ -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[];
@@ -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(
+15 -2
View File
@@ -44,8 +44,8 @@ export class InboxService {
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
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);
}
}
@@ -195,6 +195,7 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
OwnerUsername: owner,
PageCount: 0,
QrCodes: [],
IsScanned: false,
});
await this.documentRepo.save(doc);
+8
View File
@@ -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 {
@@ -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<Map<number, string>>(new Map());
const previewRef = useRef<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
const overlayRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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() {
/>
</Tooltip>
)}
<span style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
<Tooltip title={scanMode ? 'Bereich-Scan abbrechen' : 'QR-Code in Bereich scannen'}>
<Button
type="text"
shape="circle"
icon={scanning
? <LoadingOutlined style={{ fontSize: 16 }} />
: <QrcodeOutlined style={{ fontSize: 16 }} />}
onClick={() => { setScanMode((v) => !v); setDragStart(null); setDragEnd(null); }}
disabled={scanning}
style={{ color: scanMode ? '#52c41a' : '#fff' }}
/>
</Tooltip>
</div>
<div
@@ -1100,6 +1198,7 @@ export default function InboxDetailPage() {
>
{previewUrl ? (
<img
ref={imgRef}
src={previewUrl}
alt={`Vorschau Seite ${selectedPage}`}
style={{
@@ -1117,6 +1216,37 @@ export default function InboxDetailPage() {
<Spin />
)}
</div>
{/* Overlay für QR-Bereich-Scan */}
<div
ref={overlayRef}
style={{
position: 'absolute',
inset: 0,
zIndex: scanMode || dragStart ? 5 : -1,
cursor: scanMode ? 'crosshair' : 'default',
pointerEvents: scanMode ? 'all' : 'none',
}}
onMouseDown={handleOverlayMouseDown}
onMouseMove={handleOverlayMouseMove}
onMouseUp={handleOverlayMouseUp}
onMouseLeave={handleOverlayMouseLeave}
>
{dragStart && dragEnd && (
<div
style={{
position: 'absolute',
left: Math.min(dragStart.relX, dragEnd.relX),
top: Math.min(dragStart.relY, dragEnd.relY),
width: Math.abs(dragEnd.relX - dragStart.relX),
height: Math.abs(dragEnd.relY - dragStart.relY),
border: '2px dashed #1677ff',
background: 'rgba(22, 119, 255, 0.1)',
pointerEvents: 'none',
}}
/>
)}
</div>
</div>
</div>