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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
import sharp = require('sharp');
|
||||||
import { PdfService } from '../preprocessing/pdf.service';
|
import { PdfService } from '../preprocessing/pdf.service';
|
||||||
import { QrCodeService } from '../preprocessing/qr-code.service';
|
import { QrCodeService } from '../preprocessing/qr-code.service';
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +77,7 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
doc.QrCodes = qrCodes;
|
doc.QrCodes = qrCodes;
|
||||||
doc.PageCount = pageCount;
|
doc.PageCount = pageCount;
|
||||||
|
doc.IsScanned = true;
|
||||||
try {
|
try {
|
||||||
await this.documentRepo.save(doc);
|
await this.documentRepo.save(doc);
|
||||||
} catch (err: any) {
|
} 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.
|
* Rescannt alle Inbox-Dokumente — wird nach Änderungen an Eingangsdokumentarten aufgerufen.
|
||||||
* Läuft sequenziell, um PDF-Rendering nicht zu überlasten. Fire-and-forget vom Caller.
|
* 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 })
|
@Column({ type: 'int', default: 0 })
|
||||||
PageCount!: number;
|
PageCount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
IsScanned!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'json' })
|
@Column({ type: 'json' })
|
||||||
QrCodes!: StoredQrCode[];
|
QrCodes!: StoredQrCode[];
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,17 @@ export class InboxController {
|
|||||||
return new StreamableFile(createReadStream(filePath));
|
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')
|
@Post(':id/source')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async updateSource(
|
async updateSource(
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export class InboxService {
|
|||||||
|
|
||||||
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
|
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
|
||||||
const where = preferredUsername
|
const where = preferredUsername
|
||||||
? [{ Source: 'all' as InboxSource }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername }]
|
? [{ Source: 'all' as InboxSource, IsScanned: true }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername, IsScanned: true }]
|
||||||
: [{ Source: 'all' as InboxSource }];
|
: [{ Source: 'all' as InboxSource, IsScanned: true }];
|
||||||
|
|
||||||
const docs = await this.documentRepo.find({
|
const docs = await this.documentRepo.find({
|
||||||
where,
|
where,
|
||||||
@@ -212,4 +212,17 @@ export class InboxService {
|
|||||||
|
|
||||||
await this.documentRepo.save(doc);
|
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,
|
OwnerUsername: owner,
|
||||||
PageCount: 0,
|
PageCount: 0,
|
||||||
QrCodes: [],
|
QrCodes: [],
|
||||||
|
IsScanned: false,
|
||||||
});
|
});
|
||||||
await this.documentRepo.save(doc);
|
await this.documentRepo.save(doc);
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ export const inboxApi = {
|
|||||||
api
|
api
|
||||||
.post(`/api/inbox/${encodeURIComponent(id)}/source`, { source })
|
.post(`/api/inbox/${encodeURIComponent(id)}/source`, { source })
|
||||||
.then((r) => r.data),
|
.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 {
|
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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Button, Empty, Modal, Popconfirm, Space, Spin, Steps, Tag, Tooltip, Typography, message } from 'antd';
|
import { Button, Empty, Modal, Popconfirm, Space, Spin, Steps, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
RedoOutlined,
|
RedoOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
@@ -508,9 +509,15 @@ export default function InboxDetailPage() {
|
|||||||
const [selectedPage, setSelectedPage] = useState(1);
|
const [selectedPage, setSelectedPage] = useState(1);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
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 thumbsRef = useRef<Map<number, string>>(new Map());
|
||||||
const previewRef = useRef<string | null>(null);
|
const previewRef = useRef<string | null>(null);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const rotationFor = (page: number): number =>
|
const rotationFor = (page: number): number =>
|
||||||
file?.rotations?.[String(page)] ?? 0;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1085,6 +1170,19 @@ export default function InboxDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1100,6 +1198,7 @@ export default function InboxDetailPage() {
|
|||||||
>
|
>
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt={`Vorschau Seite ${selectedPage}`}
|
alt={`Vorschau Seite ${selectedPage}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -1117,6 +1216,37 @@ export default function InboxDetailPage() {
|
|||||||
<Spin />
|
<Spin />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user