feat: implement region-based QR code scanning for inbox documents
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user