Extract the geometry stuff a bit

This commit is contained in:
shamoon
2026-06-27 22:29:41 -07:00
parent 613b528b7e
commit a1fad8309f
3 changed files with 414 additions and 137 deletions
@@ -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<ResizeHandle, string> = {
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) {
@@ -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> = {}): 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 })
})
})
@@ -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))
}