make the ui usable

This commit is contained in:
shamoon committed 2026-10-19 22:06:11 -07:00
1 parent c9d62afa50
commit f5f33e0744
6 files changed
+664 -226

No files matched your search

@@ -1,6 +1,6 @@
@if (zones.length === 0) {
<p class="text-muted" i18n>
No zones defined. Load a document preview and draw rectangles to add zones.
No zones defined.
</p>
}
@@ -1,60 +1,57 @@
<pngx-page-header [title]="pageTitle" [id]="template.id">
<div class="input-group input-group-sm me-5 align-items-center">
<div class="input-group-text">
<i-bs name="file-text"></i-bs>
</div>
<input
<div class="col-12 col-md-auto">
<label class="visually-hidden" for="preview-document" i18n>Sample document</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i-bs name="file-text"></i-bs></span>
<input
id="preview-document"
type="text"
class="form-control"
[(ngModel)]="previewDocModel"
[ngbTypeahead]="searchDocuments"
#documentSearch="ngbTypeahead"
[inputFormatter]="documentFormatter"
[resultFormatter]="documentFormatter"
[resultTemplate]="documentResult"
(input)="searchStatus.set('idle')"
(selectItem)="onPreviewDocSelected($event)"
[editable]="false"
placeholder="Search documents by title..."
container="body"
popupClass="ocr-template-document-results col-11 col-md-6 overflow-y-auto"
placeholder="Search documents..."
i18n-placeholder
/>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" />
<div class="input-group-text" i18n>of {{previewPageCount}}</div>
@if (previewDocId) {
<button type="button" class="btn btn-outline-secondary" (click)="clearPreviewDoc(); documentSearch.dismissPopup()" title="Clear sample document" i18n-title aria-label="Clear sample document" i18n-aria-label>
<i-bs name="x"></i-bs>
</button>
}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="prevPage()" [disabled]="!pageImageUrl || previewPage <= 0">
<i-bs width="1.2em" height="1.2em" name="arrow-left"></i-bs>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextPage()" [disabled]="!pageImageUrl || previewPage >= (previewPageCount ?? 1) - 1">
<i-bs width="1.2em" height="1.2em" name="arrow-right"></i-bs>
</button>
<div class="input-group input-group-sm">
<button class="btn btn-outline-secondary" (click)="zoomOut()" i18n>-</button>
<span class="input-group-text">{{ zoom * 100 | number: '1.0-0' }}%</span>
<button class="btn btn-outline-secondary" (click)="zoomIn()" i18n>+</button>
<ng-template #documentResult let-document="result">
<span class="d-block text-wrap text-break">{{ documentFormatter(document) }}</span>
</ng-template>
<div class="preview-search-status small" role="status" [class.text-danger]="searchStatus() === 'error'" [class.text-muted]="searchStatus() !== 'error'">
@switch (searchStatus()) {
@case ('loading') { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span><span i18n>Searching...</span> }
@case ('empty') { <span i18n>No matching documents.</span> }
@case ('error') { <span i18n>Document search failed.</span> }
}
</div>
</div>
</pngx-page-header>
<div class="row">
<div class="col-md-4">
<div class="btn-toolbar mb-1 border-bottom">
<div class="btn-group pb-3">
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary">
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
<span class="ms-1" i18n>Close</span>
</a>
</div>
<div class="btn-group ms-auto pb-3">
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1"></span>
}
<span i18n>Save</span>
</button>
</div>
<div class="col-lg-4 mb-3">
<div class="btn-toolbar mb-1 pb-3 gap-2 border-bottom">
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary" title="Close" i18n-title aria-label="Close" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</a>
<button type="button" class="btn btn-sm btn-primary ms-auto" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
<span i18n>Save</span>
</button>
</div>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
@@ -72,10 +69,6 @@
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
<small class="text-muted" i18n>
Draw rectangles on the preview to define extraction zones. Use the
page controls above the preview to add zones on different pages.
</small>
</ng-template>
</li>
@@ -101,13 +94,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<strong>{{ zone.name }}</strong>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1"></span>
}
<span i18n>Save</span>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
</button>
</div>
@@ -305,7 +292,7 @@
<h6 i18n>Test</h6>
@if (!previewDocId) {
<p class="text-muted small mb-0" i18n>
Load a document in the Settings tab to test this zone.
No sample document selected.
</p>
} @else {
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
@@ -314,19 +301,19 @@
}
<span i18n>Test this zone</span>
</button>
@if (zoneTestResult) {
@if (zoneTestResult.error) {
<div class="alert alert-warning py-2 mt-2 mb-0 small">{{ zoneTestResult.error }}</div>
@if (zoneTestResult; as result) {
@if (result.error) {
<div class="alert alert-warning py-2 mt-2 mb-0 small" role="alert">{{ result.error }}</div>
} @else {
<dl class="row small mt-2 mb-0">
<dt class="col-sm-4" i18n>OCR text</dt>
<dd class="col-sm-8"><code>{{ zoneTestResult.raw_text || '(nothing detected)' }}</code></dd>
<dd class="col-sm-8"><code>{{ result.raw_text || '(nothing detected)' }}</code></dd>
<dt class="col-sm-4" i18n>Value</dt>
<dd class="col-sm-8"><code>{{ zoneTestResult.value || '(empty)' }}</code></dd>
@if (zoneTestResult.regex) {
<dd class="col-sm-8"><code>{{ result.value || '(empty)' }}</code></dd>
@if (result.regex) {
<dt class="col-sm-4" i18n>Validation</dt>
<dd class="col-sm-8">
@if (zoneTestResult.regex_match) {
@if (result.regex_match) {
<span class="badge bg-success" i18n>Regex matches</span>
} @else {
<span class="badge bg-danger" i18n>Regex does not match</span>
@@ -339,7 +326,7 @@
}
} @else {
<p class="text-muted" i18n>
Select a zone from the Zones tab, or draw a rectangle on the document to create one.
No zone selected.
</p>
}
</ng-template>
@@ -349,94 +336,129 @@
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<!-- Right column: Document preview with zone overlay -->
<div class="col-md-8">
@if (pageImageUrl) {
<div class="zone-preview-scroll border">
<div class="zone-preview-stage" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
class="zone-preview-image"
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
crossorigin="use-credentials"
/>
@if (imageLoaded) {
<svg
#zoneOverlay
class="zone-overlay"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(mousedown)="onOverlayMouseDown($event)"
(mousemove)="onOverlayMouseMove($event)"
(mouseup)="onOverlayMouseUp($event)"
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneFill(i)"
></rect>
<text
class="zone-label"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
<div class="col-lg-8">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2" role="toolbar" aria-label="Document preview" i18n-aria-label>
<div class="btn-group btn-group-sm text-nowrap" role="group" aria-label="Editing mode" i18n-aria-label>
<button type="button" class="btn btn-outline-primary" [class.active]="overlayMode === 'select'" [attr.aria-pressed]="overlayMode === 'select'" [disabled]="!imageLoaded" (click)="setOverlayMode('select')" title="Select and move zones" i18n-title>
<i-bs name="cursor" class="me-1"></i-bs><span i18n>Select</span>
</button>
<button type="button" class="btn btn-outline-primary" [class.active]="overlayMode === 'draw'" [attr.aria-pressed]="overlayMode === 'draw'" [disabled]="!imageLoaded" (click)="setOverlayMode('draw')" title="Draw a zone" i18n-title>
<i-bs name="pencil" class="me-1"></i-bs><span i18n>Draw</span>
</button>
</div>
<div class="input-group input-group-sm w-auto mw-100 text-nowrap order-1 order-sm-0">
<button type="button" class="btn btn-outline-secondary" title="Previous page" i18n-title aria-label="Previous page" i18n-aria-label (click)="prevPage()" [disabled]="!previewDocId || previewStatus === 'loading' || previewPage <= 0">
<i-bs name="arrow-left"></i-bs>
</button>
<span class="input-group-text" i18n>Page</span>
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" [disabled]="!previewDocId || previewStatus === 'loading'" aria-label="Preview page" i18n-aria-label />
<span class="input-group-text" i18n>of {{ previewPageCount ?? '?' }}</span>
<button type="button" class="btn btn-outline-secondary" title="Next page" i18n-title aria-label="Next page" i18n-aria-label (click)="nextPage()" [disabled]="!previewDocId || previewStatus === 'loading' || previewPage >= (previewPageCount ?? 1) - 1">
<i-bs name="arrow-right"></i-bs>
</button>
</div>
<div class="btn-group btn-group-sm text-nowrap" role="group" aria-label="Zoom" i18n-aria-label>
<button type="button" class="btn btn-outline-secondary" title="Zoom out" i18n-title aria-label="Zoom out" i18n-aria-label [disabled]="!imageLoaded || zoom <= 0.5" (click)="zoomOut()"><i-bs name="dash"></i-bs></button>
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!imageLoaded" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
<button type="button" class="btn btn-outline-secondary" title="Zoom in" i18n-title aria-label="Zoom in" i18n-aria-label [disabled]="!imageLoaded || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
</div>
</div>
<div class="zone-preview-scroll overflow-auto border" [attr.aria-busy]="previewStatus === 'loading'">
@if (previewStatus === 'error') {
<div class="py-5 px-3 text-center" role="alert">
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
<p class="my-3">{{ previewError }}</p>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="loadPreview()"><i-bs name="arrow-clockwise" class="me-1"></i-bs><span i18n>Retry</span></button>
</div>
} @else if (pageImageUrl) {
<div class="position-relative" [class.d-none]="!imageLoaded" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
(error)="onImageError()"
alt="Sample document page"
i18n-alt
class="d-block w-100"
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
crossorigin="use-credentials"
/>
@if (imageLoaded) {
<svg
#zoneOverlay
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
[class.zone-overlay-drawing]="overlayMode === 'draw'"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(pointerdown)="onOverlayPointerDown($event)"
(pointermove)="onOverlayPointerMove($event)"
(pointerup)="onOverlayPointerUp($event)"
(pointercancel)="onOverlayPointerCancel($event)"
(lostpointercapture)="onOverlayPointerCancel($event)"
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneFill(i)"
></rect>
<text
class="zone-label fw-semibold pe-none"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
@if (selectedZoneIndex === i) {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
@if (selectedZoneIndex === i && overlayMode === 'select') {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
}
}
}
</g>
</g>
}
}
}
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
@if (!imageLoaded) {
<div class="d-flex justify-content-center p-5">
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
</div>
@if (previewStatus === 'loading') {
<div class="p-5 d-flex justify-content-center align-items-center" role="status">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading page...</span>
</div>
</div>
}
</div>
</div>
} @else {
<div class="border rounded p-5 text-center text-muted">
<i-bs name="file-earmark-image" width="48" height="48"></i-bs>
<p class="mt-3" i18n>
Enter a document ID and click "Load" to preview a page and draw extraction zones.
</p>
</div>
}
} @else {
<div class="py-5 px-3 text-center text-muted">
<i-bs name="file-text" width="32" height="32"></i-bs>
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
</div>
}
</div>
</div>
</div>
@@ -4,25 +4,33 @@
.zone-preview-scroll {
max-height: 78vh;
overflow: auto;
}
.zone-preview-stage {
display: inline-block;
position: relative;
.preview-search-status {
min-height: 1.5rem;
}
.zone-preview-image {
display: block;
width: 100%;
.preview-page-input {
width: 3.5rem;
}
.preview-zoom {
min-width: 4rem;
}
// The typeahead popup is appended to body.
::ng-deep .ocr-template-document-results {
max-height: min(24rem, 60vh);
}
.zone-overlay {
height: 100%;
inset: 0;
position: absolute;
touch-action: pan-x pan-y;
}
.zone-overlay-drawing,
.zone-rect,
.zone-resize-handle {
touch-action: none;
width: 100%;
}
.zone-rect,
@@ -40,9 +48,7 @@
.zone-label {
font-family: var(--bs-font-sans-serif);
font-weight: 600;
paint-order: stroke;
pointer-events: none;
stroke: #fff;
stroke-linejoin: round;
stroke-width: 4px;
@@ -0,0 +1,261 @@
import { ElementRef } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ActivatedRoute } from '@angular/router'
import { of, Subject, throwError } from 'rxjs'
import { Document } from 'src/app/data/document'
import { OcrTemplateZone, OcrZoneTestResult } from 'src/app/data/ocr-template'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
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'
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
function zone(): OcrTemplateZone {
return {
name: 'Invoice number',
custom_field: 1,
x: 100,
y: 100,
width: 200,
height: 100,
page: 1,
ocr_language: 'eng',
transform: 'strip',
validation_regex: '',
order: 0,
}
}
describe('OcrTemplateEditorComponent', () => {
let fixture: ComponentFixture<OcrTemplateEditorComponent>
let component: OcrTemplateEditorComponent
const documents = {
list: jest.fn(),
get: jest.fn(),
}
const templates = {
testZone: jest.fn(),
getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`,
}
beforeEach(async () => {
documents.list.mockReset().mockReturnValue(of({ results: [] }))
documents.get.mockReset().mockReturnValue(of({ id: 1, page_count: 3 }))
templates.testZone.mockReset()
await TestBed.configureTestingModule({
imports: [OcrTemplateEditorComponent],
providers: [
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: new Map(), queryParams: {} } },
},
{ provide: DocumentService, useValue: documents },
{ provide: OcrTemplateService, useValue: templates },
...[CustomFieldsService, DocumentTypeService, CorrespondentService].map(
(provide) => ({
provide,
useValue: { listAll: () => of({ results: [] }) },
})
),
{ provide: ToastService, useValue: {} },
],
})
.overrideComponent(OcrTemplateEditorComponent, {
set: { template: '{{ searchStatus() }}', imports: [] },
})
.compileComponents()
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
component = fixture.componentInstance
})
afterEach(() => jest.useRealTimers())
function prepareOverlay() {
const image = document.createElement('img')
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 1000 },
clientWidth: { value: 1000 },
})
const overlay = document.createElementNS(
'http://www.w3.org/2000/svg',
'svg'
)
Object.assign(overlay, {
getScreenCTM: () => ({ inverse: () => ({}) }),
createSVGPoint: () => ({
x: 0,
y: 0,
matrixTransform() {
return { x: this.x, y: this.y }
},
}),
setPointerCapture: jest.fn(),
hasPointerCapture: () => true,
releasePointerCapture: jest.fn(),
})
component.imageRef = new ElementRef(image)
component.overlayRef = new ElementRef(overlay)
component.previewStatus = 'ready'
}
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
return Object.assign(
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
{ pointerId }
) as PointerEvent
}
it('searches across document metadata even when a template document type is set', () => {
jest.useFakeTimers()
const term$ = new Subject<string>()
const doc = { id: 1, title: 'Electricity Bill' } as Document
documents.list.mockReturnValue(of({ results: [doc] }))
component.template.document_type = 2
const found = jest.fn()
component.searchDocuments(term$).subscribe(found)
term$.next(' northw ')
jest.advanceTimersByTime(250)
expect(documents.list).toHaveBeenCalledWith(1, 10, 'created', true, {
search: 'northw',
truncate_content: true,
})
expect(found).toHaveBeenCalledWith([doc])
expect(component.searchStatus()).toBe('ready')
})
it('distinguishes an empty search from a failed request and allows another search', () => {
jest.useFakeTimers()
const term$ = new Subject<string>()
component.searchDocuments(term$).subscribe()
term$.next('missing')
jest.advanceTimersByTime(250)
expect(component.searchStatus()).toBe('empty')
documents.list.mockReturnValueOnce(throwError(() => new Error('offline')))
term$.next('offline')
jest.advanceTimersByTime(250)
expect(component.searchStatus()).toBe('error')
term$.next('retry')
jest.advanceTimersByTime(250)
expect(component.searchStatus()).toBe('empty')
})
it('renders asynchronous search feedback without a user interaction', async () => {
fixture.detectChanges()
const pending = new Subject<{ results: Document[] }>()
const term$ = new Subject<string>()
documents.list.mockReturnValue(pending)
component.searchDocuments(term$).subscribe()
term$.next('missing')
await new Promise((resolve) => setTimeout(resolve, 275))
pending.next({ results: [] })
await fixture.whenStable()
expect(fixture.nativeElement.textContent).toBe('empty')
})
it('does not create zones by dragging empty space in Select mode', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerMove(pointer(300, 200))
component.onOverlayPointerUp(pointer(300, 200))
expect(component.template.zones).toEqual([])
})
it('draws within the page bounds, uses the release position and returns to Select', () => {
prepareOverlay()
component.setOverlayMode('draw')
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(1200, 250))
expect(component.template.zones[0]).toMatchObject({
x: 100,
y: 100,
width: 900,
height: 150,
})
expect(component.selectedZoneIndex).toBe(0)
expect(component.overlayMode).toBe('select')
})
it('discards a drawing on Escape and ignores other pointers', () => {
prepareOverlay()
component.setOverlayMode('draw')
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(300, 200, 2))
expect(component.drawingRect()).not.toBeNull()
component.cancelDrawing()
component.onOverlayPointerUp(pointer(300, 200))
expect(component.template.zones).toEqual([])
expect(component.overlayMode).toBe('select')
})
it('hides results when extraction settings or geometry change', () => {
const selected = zone()
component.template.zones = [selected]
component.selectedZoneIndex = 0
component.previewDocId = 1
templates.testZone.mockReturnValue(of({ value: '123' }))
component.testZone()
expect(component.zoneTestResult?.value).toBe('123')
selected.transform = 'numeric'
expect(component.zoneTestResult).toBeNull()
component.testZone()
selected.width++
expect(component.zoneTestResult).toBeNull()
})
it('cancels a pending test when selection or sample document changes', () => {
const pending = new Subject<OcrZoneTestResult>()
component.template.zones = [zone(), zone()]
component.selectedZoneIndex = 0
component.previewDocId = 1
templates.testZone.mockReturnValue(pending)
component.testZone()
expect(component.zoneTesting).toBe(true)
component.selectZone(1)
pending.next({ value: 'Old zone' })
expect(component.zoneTesting).toBe(false)
expect(component.zoneTestResult).toBeNull()
component.testZone()
component.clearPreviewDoc()
pending.next({ value: 'Old document' })
expect(component.zoneTestResult).toBeNull()
})
it('ignores document metadata from a previous preview selection', () => {
const pending = new Subject<Document>()
documents.get.mockReturnValueOnce(pending)
component.previewDocId = 1
component.loadPreview()
component.previewDocId = 2
component.loadPreview()
pending.next({ id: 1, page_count: 99 } as Document)
expect(component.previewPageCount).toBe(3)
})
it('can retry a failed preview and clear the selected document', () => {
component.previewDocId = 1
component.loadPreview()
component.onImageError()
expect(component.previewStatus).toBe('error')
component.loadPreview()
expect(component.previewStatus).toBe('loading')
component.clearPreviewDoc()
expect(component.previewStatus).toBe('empty')
expect(component.pageImageUrl).toBeNull()
})
})
@@ -7,10 +7,11 @@ import {
inject,
OnDestroy,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { ActivatedRoute, RouterModule } from '@angular/router'
import {
NgbNavModule,
NgbPopoverModule,
@@ -29,6 +30,7 @@ import {
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
@@ -70,6 +72,7 @@ import {
getZonePage,
HANDLE_SIZE,
isZoneOnPage,
MIN_ZONE_SIZE,
MoveStart,
moveZone,
Point,
@@ -79,17 +82,28 @@ import {
type ActiveTab = 'settings' | 'zones' | 'zone'
type ZoneFieldSelection = OcrBuiltinTarget | number | null
type OverlayMode = 'select' | 'draw'
type OverlayInteraction =
| { kind: 'idle' }
| { kind: 'drawing'; rect: DrawingRect }
| { kind: 'moving'; zoneIndex: number; start: MoveStart }
| { kind: 'resizing'; zoneIndex: number; handle: ResizeHandle }
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
| {
kind: 'resizing'
pointerId: number
zoneIndex: number
handle: ResizeHandle
}
interface ZoneTest {
zone: OcrTemplateZone
documentId: number
request: ZoneTestRequest
result: OcrZoneTestResult | null
}
interface ResizeHandleMarker extends Point {
handle: ResizeHandle
}
const CUSTOM_DATE_FORMAT_CHOICE = 'custom'
const MIN_DRAWN_ZONE_SIZE = 10
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
const ZONE_COLORS = [
'#4f8ff7',
@@ -135,7 +149,6 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
})
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly templateService = inject(OcrTemplateService)
private readonly customFieldsService = inject(CustomFieldsService)
private readonly documentTypeService = inject(DocumentTypeService)
@@ -144,10 +157,27 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
private readonly toastService = inject(ToastService)
private readonly changeDetector = inject(ChangeDetectorRef)
private readonly destroy$ = new Subject<void>()
private readonly cancelZoneTest$ = new Subject<void>()
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
@ViewChild('pageImage') imageRef: ElementRef<HTMLImageElement>
imageRef: ElementRef<HTMLImageElement>
private previewResizeObserver: ResizeObserver | null = null
private readonly overlayScale = signal(1)
@ViewChild('pageImage')
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
this.previewResizeObserver?.disconnect()
this.imageRef = ref
if (!ref) return
this.previewResizeObserver = new ResizeObserver(() => {
const image = ref.nativeElement
if (image.naturalWidth && image.clientWidth) {
this.overlayScale.set(image.naturalWidth / image.clientWidth)
}
})
this.previewResizeObserver.observe(ref.nativeElement)
}
template: OcrTemplate = {
id: null,
@@ -177,7 +207,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
previewPageCount: number | null = null
private pageCountForDoc: number | null = null
pageImageUrl: string | null = null
imageLoaded = false
previewStatus: 'empty' | 'loading' | 'ready' | 'error' = 'empty'
previewError = ''
readonly searchStatus = signal<
'idle' | 'loading' | 'ready' | 'empty' | 'error'
>('idle')
zoom = 1
previewDocModel: Document | string = ''
private correspondentNames = new Map<number, string>()
@@ -194,10 +228,22 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
selectedZoneIndex: number | null = null
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
overlayCursor = 'crosshair'
overlayMode: OverlayMode = 'select'
overlayCursor = 'default'
zoneTestResult: OcrZoneTestResult | null = null
zoneTesting = false
private zoneTest: ZoneTest | null = null
get imageLoaded(): boolean {
return this.previewStatus === 'ready'
}
get zoneTestResult(): OcrZoneTestResult | null {
return this.isCurrentZoneTest() ? this.zoneTest.result : null
}
get zoneTesting(): boolean {
return this.isCurrentZoneTest() && this.zoneTest.result === null
}
showQuickCreate = false
quickCreateName = ''
@@ -282,23 +328,35 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
searchDocuments = (text$: Observable<string>): Observable<Document[]> =>
text$.pipe(
map((term) => term.trim()),
debounceTime(250),
distinctUntilChanged(),
switchMap((term) => {
if (!term || term.trim().length < 2) return of([])
const params: { title__icontains: string; document_type__id?: number } =
{ title__icontains: term.trim() }
if (this.template.document_type) {
params['document_type__id'] = this.template.document_type
if (term.length < 2) {
this.searchStatus.set('idle')
return of([])
}
return this.documentService.list(1, 10, 'created', true, params).pipe(
map((r) => r.results),
catchError(() => of([]))
)
this.searchStatus.set('loading')
return this.documentService
.list(1, 10, 'created', true, {
search: term,
truncate_content: true,
})
.pipe(
map((r) => r.results),
tap((documents) => {
this.searchStatus.set(documents.length ? 'ready' : 'empty')
}),
catchError(() => {
this.searchStatus.set('error')
return of([])
})
)
})
)
documentFormatter = (doc: Document | string): string => {
documentFormatter = (doc: Document | string | null): string => {
if (!doc) return ''
if (typeof doc === 'string') return doc
const corr = doc.correspondent
? this.correspondentNames.get(doc.correspondent)
@@ -312,6 +370,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
event.preventDefault()
const doc: Document = event.item
this.previewDocModel = doc
this.searchStatus.set('idle')
this.previewDocId = doc.id
if (!this.template.document_type && doc.document_type) {
this.template.document_type = doc.document_type
@@ -321,31 +380,45 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
clearPreviewDoc() {
this.stopOverlayInteraction()
this.clearZoneTest()
this.previewDocModel = ''
this.previewDocId = null
this.previewPageCount = null
this.pageCountForDoc = null
this.previewPage = 0
this.pageImageUrl = null
this.imageLoaded = false
this.previewStatus = 'empty'
this.previewError = ''
this.searchStatus.set('idle')
}
loadPreview() {
if (!this.previewDocId) return
if (this.pageCountForDoc !== this.previewDocId) {
this.pageCountForDoc = this.previewDocId
this.stopOverlayInteraction()
this.clearZoneTest()
this.previewStatus = 'loading'
this.previewError = ''
const documentId = this.previewDocId
if (this.pageCountForDoc !== documentId) {
this.pageCountForDoc = documentId
this.previewPageCount = null
this.documentService
.get(this.previewDocId)
.get(documentId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (doc) => {
if (this.previewDocId !== documentId) return
this.previewPageCount = doc?.page_count ?? null
if (doc && !this.previewDocModel) this.previewDocModel = doc
this.changeDetector.markForCheck()
},
error: () => {
if (this.previewDocId !== documentId) return
this.previewPageCount = null
this.pageCountForDoc = null
this.previewStatus = 'error'
this.previewError = $localize`Could not load the selected document.`
this.changeDetector.markForCheck()
},
})
@@ -354,13 +427,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
this.previewDocId,
this.previewPage
)
this.imageLoaded = false
}
goToPage(page: number) {
if (!Number.isFinite(page)) return
const max = this.previewPageCount ? this.previewPageCount - 1 : page
const clamped = Math.max(0, Math.min(page, max))
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
if (clamped === this.previewPage) return
this.previewPage = clamped
this.loadPreview()
@@ -395,35 +467,74 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
onImageLoad() {
this.imageLoaded = true
if (this.previewStatus !== 'loading') return
this.previewStatus = 'ready'
const img = this.imageRef.nativeElement
this.template.source_width = img.naturalWidth
this.template.source_height = img.naturalHeight
}
onOverlayMouseDown(event: MouseEvent) {
onImageError() {
this.stopOverlayInteraction()
this.previewStatus = 'error'
this.previewError = $localize`Could not load this page.`
}
setOverlayMode(mode: OverlayMode) {
this.stopOverlayInteraction()
this.overlayMode = mode
this.overlayCursor = mode === 'draw' ? 'crosshair' : 'default'
}
@HostListener('document:keydown.escape')
cancelDrawing() {
this.setOverlayMode('select')
}
onOverlayPointerDown(event: PointerEvent) {
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
const point = this.svgPointFromEvent(event)
if (!point) return
if (!this.imageLoaded || !point) return
event.preventDefault()
if (this.overlayMode === 'draw') {
this.clearZoneTest()
this.overlayInteraction = {
kind: 'drawing',
pointerId: event.pointerId,
rect: {
startX: point.x,
startY: point.y,
endX: point.x,
endY: point.y,
},
}
this.selectedZoneIndex = null
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
if (this.selectedZoneIndex !== null) {
const handle = this.findHandleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayInteraction = {
kind: 'resizing',
pointerId: event.pointerId,
zoneIndex: this.selectedZoneIndex,
handle,
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
}
const clickedIdx = this.findZoneAt(point)
if (clickedIdx !== null && !event.shiftKey) {
if (clickedIdx !== null) {
this.selectZone(clickedIdx)
const zone = this.template.zones[clickedIdx]
this.overlayInteraction = {
kind: 'moving',
pointerId: event.pointerId,
zoneIndex: clickedIdx,
start: {
mouseX: point.x,
@@ -432,23 +543,20 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
zoneY: zone.y,
},
}
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
return
}
// Shift+click or click on empty area starts a new zone.
this.overlayInteraction = {
kind: 'drawing',
rect: {
startX: point.x,
startY: point.y,
endX: point.x,
endY: point.y,
},
}
this.clearZoneTest()
this.selectedZoneIndex = null
}
onOverlayMouseMove(event: MouseEvent) {
onOverlayPointerMove(event: PointerEvent) {
if (
this.overlayInteraction.kind !== 'idle' &&
this.overlayInteraction.pointerId !== event.pointerId
)
return
const point = this.svgPointFromEvent(event)
if (!point) return
@@ -482,6 +590,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
private updateOverlayCursor(point: Point) {
if (this.overlayMode === 'draw') {
this.overlayCursor = 'crosshair'
return
}
if (this.selectedZoneIndex !== null) {
const handle = this.findHandleAt(point, this.selectedZoneIndex)
if (handle) {
@@ -489,30 +601,26 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
return
}
}
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'crosshair'
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'default'
}
onOverlayMouseUp(_event: MouseEvent) {
if (
this.overlayInteraction.kind === 'moving' ||
this.overlayInteraction.kind === 'resizing'
) {
this.stopOverlayInteraction()
return
}
if (this.overlayInteraction.kind !== 'drawing') return
const drawingRect = this.overlayInteraction.rect
onOverlayPointerUp(event: PointerEvent) {
if (this.overlayInteraction.kind === 'idle') return
if (this.overlayInteraction.pointerId !== event.pointerId) return
this.onOverlayPointerMove(event)
const interaction = this.overlayInteraction
this.stopOverlayInteraction()
if (interaction.kind !== 'drawing') return
const rect = this.sourceRectFromDrawing(drawingRect)
const rect = this.sourceRectFromDrawing(interaction.rect)
// Ignore tiny accidental clicks.
if (rect.w < MIN_DRAWN_ZONE_SIZE || rect.h < MIN_DRAWN_ZONE_SIZE) {
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_ZONE_SIZE) {
return
}
this.template.zones.push(this.createZoneFromRect(rect))
this.setOverlayMode('select')
this.selectZone(this.template.zones.length - 1)
}
@@ -541,15 +649,23 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
return this.customFields[0]?.id ?? null
}
@HostListener('document:mouseup')
onDocumentMouseUp() {
onOverlayPointerCancel(event: PointerEvent) {
if (this.overlayInteraction.kind === 'idle') return
if (this.overlayInteraction.pointerId !== event.pointerId) return
this.stopOverlayInteraction()
}
private stopOverlayInteraction() {
const interaction = this.overlayInteraction
this.overlayInteraction = NO_OVERLAY_INTERACTION
this.overlayCursor = 'crosshair'
this.overlayCursor = this.overlayMode === 'draw' ? 'crosshair' : 'default'
const overlay = this.overlayRef?.nativeElement
if (
interaction.kind !== 'idle' &&
overlay?.hasPointerCapture(interaction.pointerId)
) {
overlay.releasePointerCapture(interaction.pointerId)
}
}
drawingRect(): DisplayRect | null {
@@ -646,9 +762,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
overlayUnitSize(screenPixels: number): number {
const img = this.imageRef?.nativeElement
if (!img?.naturalWidth || !img.clientWidth) return screenPixels
return (screenPixels * img.naturalWidth) / img.clientWidth
return screenPixels * this.overlayScale()
}
private svgPointFromEvent(event: MouseEvent): Point | null {
@@ -661,7 +775,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
point.y = event.clientY
const svgPoint = point.matrixTransform(matrix.inverse())
return { x: svgPoint.x, y: svgPoint.y }
const size = this.imageNaturalSize()
return {
x: Math.max(0, Math.min(svgPoint.x, size.width)),
y: Math.max(0, Math.min(svgPoint.y, size.height)),
}
}
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
@@ -689,6 +807,8 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
removeZone(index: number) {
this.stopOverlayInteraction()
this.clearZoneTest()
this.template.zones.splice(index, 1)
if (this.selectedZoneIndex === index) {
this.selectedZoneIndex = null
@@ -700,7 +820,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
selectZone(index: number) {
this.selectedZoneIndex = index
this.activeTab = 'zone'
this.zoneTestResult = null
this.clearZoneTest()
const zone = this.template.zones[index]
if (zone) {
this.seedCombineDefault(zone)
@@ -711,27 +831,51 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
testZone() {
const zone = this.selectedZone
if (!zone || !this.previewDocId) return
this.zoneTesting = true
this.zoneTestResult = null
this.clearZoneTest()
const test: ZoneTest = {
zone,
documentId: this.previewDocId,
request: this.zoneTestRequest(zone),
result: null,
}
this.zoneTest = test
this.templateService
.testZone(this.previewDocId, this.zoneTestRequest(zone))
.pipe(takeUntil(this.destroy$))
.testZone(test.documentId, test.request)
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
.subscribe({
next: (res) => {
this.zoneTestResult = res
this.zoneTesting = false
test.result = res
this.changeDetector.markForCheck()
},
error: (err) => {
this.zoneTestResult = {
test.result = {
error: err.error?.error || $localize`Test failed`,
}
this.zoneTesting = false
this.changeDetector.markForCheck()
},
})
}
private clearZoneTest() {
this.cancelZoneTest$.next()
this.zoneTest = null
}
private isCurrentZoneTest(): boolean {
const test = this.zoneTest
if (
!test ||
test.zone !== this.selectedZone ||
test.documentId !== this.previewDocId
) {
return false
}
const request = this.zoneTestRequest(test.zone)
return (Object.keys(request) as (keyof ZoneTestRequest)[]).every(
(key) => request[key] === test.request[key]
)
}
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
return {
name: zone.name,
@@ -975,6 +1119,9 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
ngOnDestroy() {
this.previewResizeObserver?.disconnect()
this.clearZoneTest()
this.cancelZoneTest$.complete()
this.destroy$.next()
this.destroy$.complete()
}
+2
View File
@@ -80,6 +80,7 @@ import {
clockHistory,
cpu,
creditCard,
cursor,
dash,
dashCircle,
diagram3,
@@ -338,6 +339,7 @@ const icons = {
cpu,
cash,
creditCard,
cursor,
dash,
dashCircle,
diagram3,