diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts index e240fca0c..97c094a0a 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts @@ -52,15 +52,21 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service import { DocumentService } from 'src/app/services/rest/document.service' import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service' import { ToastService } from 'src/app/services/toast.service' - -interface DrawingRect { - startX: number - startY: number - endX: number - endY: number -} - -type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' +import { + DisplayRect, + DrawingRect, + findHandleAt, + findZoneAt, + getZoneDisplayRect, + getZonePage, + HANDLE_SIZE, + isZoneOnPage, + MoveStart, + moveZone, + ResizeHandle, + resizeZone, + sourceRectFromDrawing, +} from './zone-geometry' type ActiveTab = 'settings' | 'zones' | 'zone' @@ -147,11 +153,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { isResizing = false resizeHandle: ResizeHandle | null = null resizeZoneIndex: number | null = null - private readonly HANDLE_SIZE = 8 isMoving = false moveZoneIndex: number | null = null - private moveStart = { mouseX: 0, mouseY: 0, zoneX: 0, zoneY: 0 } + private moveStart: MoveStart = { mouseX: 0, mouseY: 0, zoneX: 0, zoneY: 0 } zoneTestResult: OcrZoneTestResult | null = null zoneTesting = false @@ -340,13 +345,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } zonePage(zone: OcrTemplateZone): number { - const v = zone.page ?? 1 - if (v === -1) return this.previewPageCount ?? this.previewPage + 1 - return v >= 1 ? v : 1 + return getZonePage(zone, this.previewPage, this.previewPageCount) } private isOnCurrentPage(zone: OcrTemplateZone): boolean { - return this.zonePage(zone) === this.previewPage + 1 + return isZoneOnPage(zone, this.previewPage, this.previewPageCount) } onImageLoad() { @@ -364,7 +367,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { const y = event.clientY - rect.top if (this.selectedZoneIndex !== null) { - const handle = this.findHandleAt(x, y, this.selectedZoneIndex) + const handle = this.findHandleAt({ x, y }, this.selectedZoneIndex) if (handle) { this.isResizing = true this.resizeHandle = handle @@ -373,7 +376,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } } - const clickedIdx = this.findZoneAt(x, y) + const clickedIdx = this.findZoneAt({ x, y }) if (clickedIdx !== null && !event.shiftKey) { this.selectZone(clickedIdx) const zone = this.template.zones[clickedIdx] @@ -401,22 +404,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } if (this.isMoving && this.moveZoneIndex !== null) { - const zone = this.template.zones[this.moveZoneIndex] - const canvas = this.canvasRef.nativeElement - const img = this.imageRef.nativeElement - const srcW = zone.zone_source_width || img.naturalWidth - const srcH = zone.zone_source_height || img.naturalHeight - const scaleX = srcW / canvas.width - const scaleY = srcH / canvas.height - const dx = Math.round((mx - this.moveStart.mouseX) * scaleX) - const dy = Math.round((my - this.moveStart.mouseY) * scaleY) - zone.x = Math.max( - 0, - Math.min(this.moveStart.zoneX + dx, srcW - zone.width) - ) - zone.y = Math.max( - 0, - Math.min(this.moveStart.zoneY + dy, srcH - zone.height) + moveZone( + this.template.zones[this.moveZoneIndex], + { x: mx, y: my }, + this.moveStart, + this.canvasSize(), + this.imageNaturalSize() ) this.redrawCanvas() return @@ -432,7 +425,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { // Cursor feedback: resize handle > move (over a zone) > crosshair. const canvas = this.canvasRef.nativeElement if (this.selectedZoneIndex !== null) { - const handle = this.findHandleAt(mx, my, this.selectedZoneIndex) + const handle = this.findHandleAt({ x: mx, y: my }, this.selectedZoneIndex) if (handle) { const cursorMap: Record = { nw: 'nw-resize', @@ -449,7 +442,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } } canvas.style.cursor = - this.findZoneAt(mx, my) !== null ? 'move' : 'crosshair' + this.findZoneAt({ x: mx, y: my }) !== null ? 'move' : 'crosshair' } onCanvasMouseUp(event: MouseEvent) { @@ -466,27 +459,15 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { if (!this.isDrawing || !this.currentRect) return this.isDrawing = false - const canvas = this.canvasRef.nativeElement const img = this.imageRef.nativeElement - - const scaleX = img.naturalWidth / canvas.width - const scaleY = img.naturalHeight / canvas.height - - const x = Math.round( - Math.min(this.currentRect.startX, this.currentRect.endX) * scaleX - ) - const y = Math.round( - Math.min(this.currentRect.startY, this.currentRect.endY) * scaleY - ) - const w = Math.round( - Math.abs(this.currentRect.endX - this.currentRect.startX) * scaleX - ) - const h = Math.round( - Math.abs(this.currentRect.endY - this.currentRect.startY) * scaleY + const rect = sourceRectFromDrawing( + this.currentRect, + this.canvasSize(), + this.imageNaturalSize() ) // Ignore tiny accidental clicks. - if (w < 10 || h < 10) { + if (rect.w < 10 || rect.h < 10) { this.currentRect = null this.redrawCanvas() return @@ -497,10 +478,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { target: 'custom_field', custom_field: this.customFields.length > 0 ? this.customFields[0].id : null, - x, - y, - width: w, - height: h, + x: rect.x, + y: rect.y, + width: rect.w, + height: rect.h, page: this.previewPage + 1, ocr_language: 'deu+eng', transform: 'strip', @@ -533,110 +514,51 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { this.currentRect = null } - private getZoneDisplayRect( - zoneIdx: number - ): { x: number; y: number; w: number; h: number } | null { + private getZoneDisplayRect(zoneIdx: number): DisplayRect | null { const canvas = this.canvasRef?.nativeElement const img = this.imageRef?.nativeElement if (!canvas || !img || !img.naturalWidth) return null const zone = this.template.zones[zoneIdx] if (!zone) return null if (!this.isOnCurrentPage(zone)) return null - const srcW = zone.zone_source_width || img.naturalWidth - const srcH = zone.zone_source_height || img.naturalHeight - const scaleX = canvas.width / srcW - const scaleY = canvas.height / srcH - return { - x: zone.x * scaleX, - y: zone.y * scaleY, - w: zone.width * scaleX, - h: zone.height * scaleY, - } + return getZoneDisplayRect(zone, this.canvasSize(), this.imageNaturalSize()) } private findHandleAt( - mx: number, - my: number, + point: { x: number; y: number }, zoneIdx: number ): ResizeHandle | null { const r = this.getZoneDisplayRect(zoneIdx) if (!r) return null - const hs = this.HANDLE_SIZE - const handles: [ResizeHandle, number, number][] = [ - ['nw', r.x, r.y], - ['n', r.x + r.w / 2, r.y], - ['ne', r.x + r.w, r.y], - ['w', r.x, r.y + r.h / 2], - ['e', r.x + r.w, r.y + r.h / 2], - ['sw', r.x, r.y + r.h], - ['s', r.x + r.w / 2, r.y + r.h], - ['se', r.x + r.w, r.y + r.h], - ] - for (const [name, hx, hy] of handles) { - if (Math.abs(mx - hx) <= hs && Math.abs(my - hy) <= hs) return name - } - return null + return findHandleAt(point, r) } private applyResize(mx: number, my: number) { if (this.resizeZoneIndex === null || !this.resizeHandle) return - const canvas = this.canvasRef.nativeElement - const img = this.imageRef.nativeElement const zone = this.template.zones[this.resizeZoneIndex] if (!zone) return - const srcW = zone.zone_source_width || img.naturalWidth - const srcH = zone.zone_source_height || img.naturalHeight - const scaleX = srcW / canvas.width - const scaleY = srcH / canvas.height - const imgX = Math.max(0, Math.min(srcW, Math.round(mx * scaleX))) - const imgY = Math.max(0, Math.min(srcH, Math.round(my * scaleY))) - const handle = this.resizeHandle - - if (handle.includes('w')) { - const right = Math.min(zone.x + zone.width, srcW) - zone.x = Math.max(0, Math.min(imgX, right - 10)) - zone.width = right - zone.x - } - if (handle.includes('e')) { - zone.width = Math.max(10, imgX - zone.x) - } - if (handle.includes('n')) { - const bottom = Math.min(zone.y + zone.height, srcH) - zone.y = Math.max(0, Math.min(imgY, bottom - 10)) - zone.height = bottom - zone.y - } - if (handle.includes('s')) { - zone.height = Math.max(10, imgY - zone.y) - } + resizeZone( + zone, + this.resizeHandle, + { x: mx, y: my }, + this.canvasSize(), + this.imageNaturalSize() + ) } - private findZoneAt(displayX: number, displayY: number): number | null { - const canvas = this.canvasRef.nativeElement + private findZoneAt(point: { x: number; y: number }): number | null { const img = this.imageRef.nativeElement if (!img.naturalWidth) return null - for (let i = this.template.zones.length - 1; i >= 0; i--) { - const z = this.template.zones[i] - if (!this.isOnCurrentPage(z)) continue - const srcW = z.zone_source_width || img.naturalWidth - const srcH = z.zone_source_height || img.naturalHeight - const scaleX = canvas.width / srcW - const scaleY = canvas.height / srcH - const zx = z.x * scaleX - const zy = z.y * scaleY - const zw = z.width * scaleX - const zh = z.height * scaleY - if ( - displayX >= zx && - displayX <= zx + zw && - displayY >= zy && - displayY <= zy + zh - ) { - return i - } - } - return null + return findZoneAt( + point, + this.template.zones, + this.previewPage, + this.previewPageCount, + this.canvasSize(), + this.imageNaturalSize() + ) } redrawCanvas() { @@ -703,7 +625,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { ctx.textBaseline = 'alphabetic' if (idx === this.selectedZoneIndex) { - const hs = this.HANDLE_SIZE ctx.fillStyle = color const handles = [ [x, y], @@ -716,7 +637,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { [x + w, y + h], ] for (const [hx, hy] of handles) { - ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs) + ctx.fillRect( + hx - HANDLE_SIZE / 2, + hy - HANDLE_SIZE / 2, + HANDLE_SIZE, + HANDLE_SIZE + ) } } }) @@ -734,6 +660,16 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } } + private canvasSize() { + const canvas = this.canvasRef.nativeElement + return { width: canvas.width, height: canvas.height } + } + + private imageNaturalSize() { + const img = this.imageRef.nativeElement + return { width: img.naturalWidth, height: img.naturalHeight } + } + removeZone(index: number) { this.template.zones.splice(index, 1) if (this.selectedZoneIndex === index) { diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts new file mode 100644 index 000000000..84783b432 --- /dev/null +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts @@ -0,0 +1,140 @@ +import { OcrTemplateZone } from 'src/app/data/ocr-template' +import { + findHandleAt, + findZoneAt, + getZoneDisplayRect, + getZonePage, + isZoneOnPage, + moveZone, + resizeZone, + sourceRectFromDrawing, +} from './zone-geometry' + +function zone(overrides: Partial = {}): OcrTemplateZone { + return { + name: 'Zone', + target: 'custom_field', + custom_field: 1, + x: 100, + y: 200, + width: 300, + height: 400, + page: 1, + ocr_language: 'eng', + transform: 'strip', + validation_regex: '', + order: 0, + ...overrides, + } +} + +describe('OCR template editor geometry', () => { + it('normalizes zone pages', () => { + expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2) + expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5) + expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3) + expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1) + expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1) + }) + + it('checks whether a zone is on the current preview page', () => { + expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true) + expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false) + expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true) + }) + + it('scales source coordinates to canvas display coordinates', () => { + expect( + getZoneDisplayRect( + zone({ x: 100, y: 200, width: 300, height: 400 }), + { width: 500, height: 1000 }, + { width: 1000, height: 2000 } + ) + ).toEqual({ x: 50, y: 100, w: 150, h: 200 }) + }) + + it('uses per-zone source dimensions when present', () => { + expect( + getZoneDisplayRect( + zone({ + x: 100, + y: 100, + width: 100, + height: 100, + zone_source_width: 1000, + zone_source_height: 1000, + }), + { width: 500, height: 500 }, + { width: 2000, height: 2000 } + ) + ).toEqual({ x: 50, y: 50, w: 50, h: 50 }) + }) + + it('finds zones from topmost to bottommost on the current page', () => { + const zones = [ + zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }), + zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }), + zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }), + ] + + expect( + findZoneAt( + { x: 25, y: 25 }, + zones, + 0, + 2, + { width: 100, height: 100 }, + { width: 100, height: 100 } + ) + ).toBe(1) + }) + + it('finds resize handles around a display rect', () => { + const rect = { x: 10, y: 20, w: 100, h: 200 } + + expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw') + expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se') + expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n') + expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull() + }) + + it('moves zones without leaving source image bounds', () => { + const z = zone({ x: 50, y: 50, width: 100, height: 100 }) + + moveZone( + z, + { x: 500, y: 500 }, + { mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 }, + { width: 500, height: 500 }, + { width: 500, height: 500 } + ) + + expect(z.x).toBe(400) + expect(z.y).toBe(400) + }) + + it('resizes zones without leaving source image bounds', () => { + const z = zone({ x: 50, y: 50, width: 100, height: 100 }) + + resizeZone( + z, + 'se', + { x: 500, y: 500 }, + { width: 500, height: 500 }, + { width: 200, height: 200 } + ) + + expect(z.width).toBe(150) + expect(z.height).toBe(150) + }) + + it('converts drawn canvas rectangles to source rectangles', () => { + expect( + sourceRectFromDrawing( + { startX: 100, startY: 200, endX: 50, endY: 100 }, + { width: 500, height: 1000 }, + { width: 1000, height: 2000 } + ) + ).toEqual({ x: 100, y: 200, w: 100, h: 200 }) + }) +}) diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.ts new file mode 100644 index 000000000..489dd58ba --- /dev/null +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.ts @@ -0,0 +1,201 @@ +import { OcrTemplateZone } from 'src/app/data/ocr-template' + +export interface DrawingRect { + startX: number + startY: number + endX: number + endY: number +} + +export interface Dimensions { + width: number + height: number +} + +export interface Point { + x: number + y: number +} + +export interface DisplayRect { + x: number + y: number + w: number + h: number +} + +export interface MoveStart { + mouseX: number + mouseY: number + zoneX: number + zoneY: number +} + +export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' + +export const HANDLE_SIZE = 8 +export const MIN_ZONE_SIZE = 10 + +export function getZonePage( + zone: OcrTemplateZone, + previewPage: number, + previewPageCount: number | null +): number { + const page = zone.page ?? 1 + if (page === -1) return previewPageCount ?? previewPage + 1 + return page >= 1 ? page : 1 +} + +export function isZoneOnPage( + zone: OcrTemplateZone, + previewPage: number, + previewPageCount: number | null +): boolean { + return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1 +} + +export function getZoneSourceSize( + zone: OcrTemplateZone, + imageSize: Dimensions +): Dimensions { + return { + width: zone.zone_source_width || imageSize.width, + height: zone.zone_source_height || imageSize.height, + } +} + +export function getZoneDisplayRect( + zone: OcrTemplateZone, + canvasSize: Dimensions, + imageSize: Dimensions +): DisplayRect { + const sourceSize = getZoneSourceSize(zone, imageSize) + const scaleX = canvasSize.width / sourceSize.width + const scaleY = canvasSize.height / sourceSize.height + + return { + x: zone.x * scaleX, + y: zone.y * scaleY, + w: zone.width * scaleX, + h: zone.height * scaleY, + } +} + +export function findHandleAt( + point: Point, + rect: DisplayRect, + handleSize = HANDLE_SIZE +): ResizeHandle | null { + const handles: [ResizeHandle, number, number][] = [ + ['nw', rect.x, rect.y], + ['n', rect.x + rect.w / 2, rect.y], + ['ne', rect.x + rect.w, rect.y], + ['w', rect.x, rect.y + rect.h / 2], + ['e', rect.x + rect.w, rect.y + rect.h / 2], + ['sw', rect.x, rect.y + rect.h], + ['s', rect.x + rect.w / 2, rect.y + rect.h], + ['se', rect.x + rect.w, rect.y + rect.h], + ] + + return ( + handles.find( + ([, x, y]) => + Math.abs(point.x - x) <= handleSize && + Math.abs(point.y - y) <= handleSize + )?.[0] ?? null + ) +} + +export function findZoneAt( + point: Point, + zones: OcrTemplateZone[], + previewPage: number, + previewPageCount: number | null, + canvasSize: Dimensions, + imageSize: Dimensions +): number | null { + for (let i = zones.length - 1; i >= 0; i--) { + const zone = zones[i] + if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue + const rect = getZoneDisplayRect(zone, canvasSize, imageSize) + + if ( + point.x >= rect.x && + point.x <= rect.x + rect.w && + point.y >= rect.y && + point.y <= rect.y + rect.h + ) { + return i + } + } + + return null +} + +export function moveZone( + zone: OcrTemplateZone, + point: Point, + moveStart: MoveStart, + canvasSize: Dimensions, + imageSize: Dimensions +) { + const sourceSize = getZoneSourceSize(zone, imageSize) + const scaleX = sourceSize.width / canvasSize.width + const scaleY = sourceSize.height / canvasSize.height + const dx = Math.round((point.x - moveStart.mouseX) * scaleX) + const dy = Math.round((point.y - moveStart.mouseY) * scaleY) + + zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width) + zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height) +} + +export function resizeZone( + zone: OcrTemplateZone, + handle: ResizeHandle, + point: Point, + canvasSize: Dimensions, + imageSize: Dimensions +) { + const sourceSize = getZoneSourceSize(zone, imageSize) + const scaleX = sourceSize.width / canvasSize.width + const scaleY = sourceSize.height / canvasSize.height + const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width) + const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height) + + if (handle.includes('w')) { + const right = Math.min(zone.x + zone.width, sourceSize.width) + zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE) + zone.width = right - zone.x + } + if (handle.includes('e')) { + zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x) + } + if (handle.includes('n')) { + const bottom = Math.min(zone.y + zone.height, sourceSize.height) + zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE) + zone.height = bottom - zone.y + } + if (handle.includes('s')) { + zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y) + } +} + +export function sourceRectFromDrawing( + rect: DrawingRect, + canvasSize: Dimensions, + imageSize: Dimensions +): DisplayRect { + const scaleX = imageSize.width / canvasSize.width + const scaleY = imageSize.height / canvasSize.height + + return { + x: Math.round(Math.min(rect.startX, rect.endX) * scaleX), + y: Math.round(Math.min(rect.startY, rect.endY) * scaleY), + w: Math.round(Math.abs(rect.endX - rect.startX) * scaleX), + h: Math.round(Math.abs(rect.endY - rect.startY) * scaleY), + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)) +}