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 27a8ff319..95d24e18a 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 @@ -76,6 +76,7 @@ import { DrawingRect, findHandleAt, findZoneAt, + getResizeHandles, getZoneColor, getZoneDisplayRect, getZonePage, @@ -107,9 +108,6 @@ interface ZoneTest { request: ZoneTestRequest result: OcrZoneTestResult | null } -interface ResizeHandleMarker extends Point { - handle: ResizeHandle -} const CUSTOM_DATE_FORMAT_CHOICE = 'custom' const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' } @@ -581,7 +579,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { this.template.zones[this.overlayInteraction.zoneIndex], point, this.overlayInteraction.start, - this.imageNaturalSize(), this.imageNaturalSize() ) return @@ -678,11 +675,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { const zone = this.template.zones[zoneIdx] if (!zone) return null if (!this.isOnCurrentPage(zone)) return null - return getZoneDisplayRect( - zone, - this.imageNaturalSize(), - this.imageNaturalSize() - ) + return getZoneDisplayRect(zone, this.imageNaturalSize()) } private findHandleAt(point: Point, zoneIdx: number): ResizeHandle | null { @@ -694,13 +687,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { private applyResize(zoneIndex: number, handle: ResizeHandle, point: Point) { const zone = this.template.zones[zoneIndex] if (!zone) return - resizeZone( - zone, - handle, - point, - this.imageNaturalSize(), - this.imageNaturalSize() - ) + resizeZone(zone, handle, point, this.imageNaturalSize()) } private findZoneAt(point: Point): number | null { @@ -712,7 +699,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { this.template.zones, this.previewPage, this.previewPageCount, - this.imageNaturalSize(), this.imageNaturalSize() ) } @@ -738,18 +724,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4)) } - resizeHandles(rect: DisplayRect): ResizeHandleMarker[] { - return [ - { handle: 'nw', x: rect.x, y: rect.y }, - { handle: 'n', x: rect.x + rect.w / 2, y: rect.y }, - { handle: 'ne', x: rect.x + rect.w, y: rect.y }, - { handle: 'w', x: rect.x, y: rect.y + rect.h / 2 }, - { handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 }, - { handle: 'sw', x: rect.x, y: rect.y + rect.h }, - { handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h }, - { handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h }, - ] - } + readonly resizeHandles = getResizeHandles overlayHandleSize(): number { return this.overlayUnitSize(HANDLE_SIZE) 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 index 84783b432..161416b94 100644 --- 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 @@ -2,12 +2,12 @@ import { OcrTemplateZone } from 'src/app/data/ocr-template' import { findHandleAt, findZoneAt, + getResizeHandles, getZoneDisplayRect, getZonePage, isZoneOnPage, moveZone, resizeZone, - sourceRectFromDrawing, } from './zone-geometry' function zone(overrides: Partial = {}): OcrTemplateZone { @@ -43,14 +43,13 @@ describe('OCR template editor geometry', () => { expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true) }) - it('scales source coordinates to canvas display coordinates', () => { + it('uses natural image coordinates when source dimensions are unset', () => { 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 }) + getZoneDisplayRect(zone({ x: 100, y: 200, width: 300, height: 400 }), { + width: 1000, + height: 2000, + }) + ).toEqual({ x: 100, y: 200, w: 300, h: 400 }) }) it('uses per-zone source dimensions when present', () => { @@ -64,10 +63,9 @@ describe('OCR template editor geometry', () => { zone_source_width: 1000, zone_source_height: 1000, }), - { width: 500, height: 500 }, - { width: 2000, height: 2000 } + { width: 500, height: 750 } ) - ).toEqual({ x: 50, y: 50, w: 50, h: 50 }) + ).toEqual({ x: 50, y: 75, w: 50, h: 75 }) }) it('finds zones from topmost to bottommost on the current page', () => { @@ -78,14 +76,7 @@ describe('OCR template editor geometry', () => { ] expect( - findZoneAt( - { x: 25, y: 25 }, - zones, - 0, - 2, - { width: 100, height: 100 }, - { width: 100, height: 100 } - ) + findZoneAt({ x: 25, y: 25 }, zones, 0, 2, { width: 100, height: 100 }) ).toBe(1) }) @@ -96,6 +87,27 @@ describe('OCR template editor geometry', () => { 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() + expect(findHandleAt({ x: 19, y: 20 }, rect, 8)).toBeNull() + expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw') + }) + + it('uses the rendered handle positions for hit testing', () => { + const rect = { x: 10, y: 20, w: 100, h: 200 } + const handles = getResizeHandles(rect) + + expect(handles).toEqual([ + { handle: 'nw', x: 10, y: 20 }, + { handle: 'n', x: 60, y: 20 }, + { handle: 'ne', x: 110, y: 20 }, + { handle: 'w', x: 10, y: 120 }, + { handle: 'e', x: 110, y: 120 }, + { handle: 'sw', x: 10, y: 220 }, + { handle: 's', x: 60, y: 220 }, + { handle: 'se', x: 110, y: 220 }, + ]) + for (const marker of handles) { + expect(findHandleAt(marker, rect)).toBe(marker.handle) + } }) it('moves zones without leaving source image bounds', () => { @@ -105,7 +117,6 @@ describe('OCR template editor geometry', () => { z, { x: 500, y: 500 }, { mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 }, - { width: 500, height: 500 }, { width: 500, height: 500 } ) @@ -114,27 +125,39 @@ describe('OCR template editor geometry', () => { }) it('resizes zones without leaving source image bounds', () => { - const z = zone({ x: 50, y: 50, width: 100, height: 100 }) + const z = zone({ + x: 50, + y: 50, + width: 100, + height: 100, + zone_source_width: 200, + zone_source_height: 200, + }) - resizeZone( - z, - 'se', - { x: 500, y: 500 }, - { width: 500, height: 500 }, - { width: 200, height: 200 } - ) + resizeZone(z, 'se', { x: 500, y: 500 }, { width: 500, height: 500 }) 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 }) + it('moves zones in their original source coordinates on a different-size preview', () => { + const z = zone({ + x: 50, + y: 50, + width: 100, + height: 100, + zone_source_width: 1000, + zone_source_height: 2000, + }) + + moveZone( + z, + { x: 75, y: 75 }, + { mouseX: 25, mouseY: 25, zoneX: 50, zoneY: 50 }, + { width: 500, height: 500 } + ) + + expect(z.x).toBe(150) + expect(z.y).toBe(250) }) }) 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 index 8d6ff220b..ac41812cf 100644 --- 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 @@ -33,6 +33,10 @@ export interface MoveStart { export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' +export interface ResizeHandleMarker extends Point { + handle: ResizeHandle +} + export const HANDLE_SIZE = 8 export const MIN_ZONE_SIZE = 10 @@ -70,7 +74,7 @@ export function isZoneOnPage( return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1 } -export function getZoneSourceSize( +function getZoneSourceSize( zone: OcrTemplateZone, imageSize: Dimensions ): Dimensions { @@ -82,12 +86,12 @@ export function getZoneSourceSize( 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 + // The SVG viewBox uses the preview image's natural dimensions. + const scaleX = imageSize.width / sourceSize.width + const scaleY = imageSize.height / sourceSize.height return { x: zone.x * scaleX, @@ -97,28 +101,30 @@ export function getZoneDisplayRect( } } +export function getResizeHandles(rect: DisplayRect): ResizeHandleMarker[] { + return [ + { handle: 'nw', x: rect.x, y: rect.y }, + { handle: 'n', x: rect.x + rect.w / 2, y: rect.y }, + { handle: 'ne', x: rect.x + rect.w, y: rect.y }, + { handle: 'w', x: rect.x, y: rect.y + rect.h / 2 }, + { handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 }, + { handle: 'sw', x: rect.x, y: rect.y + rect.h }, + { handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h }, + { handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h }, + ] +} + 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]) => + getResizeHandles(rect).find( + ({ x, y }) => Math.abs(point.x - x) <= handleSize && Math.abs(point.y - y) <= handleSize - )?.[0] ?? null + )?.handle ?? null ) } @@ -127,13 +133,12 @@ export function findZoneAt( 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) + const rect = getZoneDisplayRect(zone, imageSize) if ( point.x >= rect.x && @@ -152,12 +157,11 @@ 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 scaleX = sourceSize.width / imageSize.width + const scaleY = sourceSize.height / imageSize.height const dx = Math.round((point.x - moveStart.mouseX) * scaleX) const dy = Math.round((point.y - moveStart.mouseY) * scaleY) @@ -169,12 +173,11 @@ 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 scaleX = sourceSize.width / imageSize.width + const scaleY = sourceSize.height / imageSize.height const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width) const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height) @@ -196,22 +199,6 @@ export function resizeZone( } } -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)) }