Gosh so much cleanup

This commit is contained in:
shamoon committed 2026-10-20 10:03:55 -07:00
1 parent 123df2e826
commit 29ea9cec66
7 files changed
+388 -824

No files matched your search

@@ -18,10 +18,10 @@ export class OcrTemplateEditorZoneListComponent {
@Input() zones: OcrTemplateZone[] = []
@Input() customFields: CustomField[] = []
@Output() zoneSelected = new EventEmitter<number>()
@Output() zoneRemoved = new EventEmitter<number>()
@Output() public readonly zoneSelected = new EventEmitter<number>()
@Output() public readonly zoneRemoved = new EventEmitter<number>()
readonly zoneColor = getZoneColor
public readonly zoneColor = getZoneColor
hasField(zone: OcrTemplateZone): boolean {
return getZoneFieldKey(zone) !== null
@@ -35,7 +35,7 @@
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
<i-bs name="arrow-left" class="me-1"></i-bs><ng-container i18n>All zones</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting" [title]="previewDocId ? '' : noSampleDocumentTitle">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting">
@if (zoneTesting) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
} @else {
@@ -43,7 +43,7 @@
}
<ng-container i18n>Test</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeZone(selectedZoneIndex)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
<i-bs name="trash"></i-bs>
</button>
</div>
@@ -117,7 +117,7 @@
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
<pngx-input-select
[items]="ocrLanguageOptions"
[items]="languageOptions"
[multiple]="true"
[ngModel]="ocrLanguageArray(zone)"
(ngModelChange)="setOcrLanguages(zone, $event)"
@@ -130,32 +130,15 @@
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
@if (zone.transform === OcrZoneTransform.Date) {
<pngx-input-select
[items]="dateFormatItems"
[ngModel]="dateFormatChoice(zone)"
(ngModelChange)="setDateFormatChoice(zone, $event)"
<pngx-input-text
[(ngModel)]="zone.date_format"
title="Date format"
i18n-title
></pngx-input-select>
@if (usesCustomDateFormat(zone)) {
<div class="input-group mt-n2 mb-3">
<input type="text" class="form-control font-monospace" [(ngModel)]="zone.date_format" placeholder="%d.%m.%Y" aria-label="Custom date format" i18n-aria-label />
<button class="btn btn-outline-secondary" type="button" [ngbPopover]="dateFmtHelp" [autoClose]="true" title="Date format help" i18n-title>
<i-bs name="question-circle"></i-bs>
</button>
</div>
<ng-template #dateFmtHelp>
<p class="mb-1" i18n>Python date codes:</p>
<ul class="mb-1 ps-3">
<li><code>%d</code> <ng-container i18n>day (01-31)</ng-container></li>
<li><code>%m</code> <ng-container i18n>month (01-12)</ng-container></li>
<li><code>%Y</code> <ng-container i18n>year, 4-digit</ng-container></li>
<li><code>%y</code> <ng-container i18n>year, 2-digit</ng-container></li>
<li><code>%b</code> <ng-container i18n>month name (Jan)</ng-container></li>
</ul>
<span i18n>Example:</span> <code>%d.%m.%Y</code> -> 03.03.2026
</ng-template>
}
placeholder="Auto-detect"
i18n-placeholder
hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically."
i18n-hint
></pngx-input-text>
}
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="e.g. \d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
@@ -176,62 +159,24 @@
<div class="col-lg-8 d-flex flex-column">
<div class="mb-2 flex-shrink-0">
<label class="visually-hidden" for="preview-document" i18n>Sample document</label>
<div class="input-group input-group-sm">
<span class="input-group-text">
@if (searchStatus() === 'loading') {
<span class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden" i18n>Searching...</span></span>
} @else {
<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"
container="body"
popupClass="ocr-template-document-results col-11 col-md-6 overflow-y-auto"
placeholder="Search for a sample document..."
i18n-placeholder
/>
@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>
<ng-template #documentResult let-document="result">
<span class="d-block text-wrap text-break">{{ documentFormatter(document) }}</span>
</ng-template>
<pngx-input-document-link
[ngModel]="sampleDocumentIds"
(ngModelChange)="setSampleDocument($event)"
[minimal]="true"
placeholder="Search for a sample document"
i18n-placeholder
></pngx-input-document-link>
</div>
@if (searchStatus() === 'empty' || searchStatus() === 'error') {
<div class="small mb-2" role="status" [class.text-danger]="searchStatus() === 'error'" [class.text-muted]="searchStatus() === 'empty'">
@if (searchStatus() === 'error') {
<span i18n>Document search failed.</span>
} @else {
<span i18n>No matching documents.</span>
}
</div>
}
<div #previewContainer class="position-relative flex-grow-1 overflow-hidden">
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewStatus() === 'loading'">
@if (previewStatus() === 'error') {
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewDocId && !pageLoaded()">
@if (pageFailed()) {
<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>
<p class="my-3" i18n>Could not load this page.</p>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><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">
} @else if (previewDocId) {
<div class="position-relative" [class.d-none]="!pageLoaded()" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
@@ -242,7 +187,7 @@
class="d-block w-100"
crossorigin="use-credentials"
/>
@if (imageLoaded) {
@if (pageLoaded()) {
<svg
#zoneOverlay
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
@@ -304,8 +249,8 @@
</svg>
}
</div>
@if (previewStatus() === 'loading') {
<div class="p-5 d-flex justify-content-center align-items-center" role="status">
@if (!pageLoaded()) {
<div class="p-5 d-flex justify-content-center align-items-center">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading page...</span>
</div>
@@ -318,20 +263,20 @@
</div>
}
</div>
@if (previewDocId && previewStatus() !== 'error') {
@if (previewDocId && !pageFailed()) {
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3">
<div cdkDrag [cdkDragBoundary]="previewContainer" class="d-flex gap-2 p-1 bg-body border rounded shadow">
<span cdkDragHandle class="d-flex align-items-center text-secondary" title="Move preview controls" i18n-title aria-label="Move preview controls" i18n-aria-label>
<i-bs name="grip-vertical"></i-bs>
</span>
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="previewStatus() === 'loading'" aria-label="Page" i18n-aria-label />
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="!pageLoaded()" aria-label="Page" i18n-aria-label />
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
</div>
<div class="input-group input-group-sm w-auto flex-nowrap" role="group" aria-label="Zoom" i18n-aria-label>
<button type="button" class="btn btn-outline-secondary" 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" aria-label="Zoom in" i18n-aria-label [disabled]="!imageLoaded || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!pageLoaded() || 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]="!pageLoaded()" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!pageLoaded() || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
</div>
</div>
</div>
@@ -26,11 +26,6 @@
cursor: move;
}
// The typeahead popup is appended to body.
::ng-deep .ocr-template-document-results {
max-height: min(24rem, 60vh);
}
.zone-overlay {
touch-action: none;
}
@@ -1,10 +1,12 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ElementRef } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, Subject, throwError } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import {
OcrTemplate,
OcrTemplateZone,
@@ -12,16 +14,18 @@ import {
OcrZoneTestResult,
OcrZoneTransform,
} from 'src/app/data/ocr-template'
import { Results } from 'src/app/data/results'
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 {
const customFields: CustomField[] = [
{ id: 1, name: 'Invoice number', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Status', data_type: CustomFieldDataType.Select },
]
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
return {
name: 'Invoice number',
target: OcrZoneTarget.CustomField,
@@ -35,80 +39,54 @@ function zone(): OcrTemplateZone {
transform: OcrZoneTransform.Strip,
validation_regex: '',
order: 0,
...overrides,
}
}
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
return Object.assign(
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
{ pointerId }
) as PointerEvent
}
describe('OcrTemplateEditorComponent', () => {
let fixture: ComponentFixture<OcrTemplateEditorComponent>
let component: OcrTemplateEditorComponent
const documents = {
list: jest.fn(),
get: jest.fn(),
}
const templates = {
testZone: jest.fn(),
create: jest.fn(),
update: jest.fn(),
getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`,
}
const modal = { open: jest.fn() }
let fixture: ComponentFixture<OcrTemplateEditorComponent>
let documentService: DocumentService
let templateService: OcrTemplateService
let modalService: NgbModal
const activeModal = { close: jest.fn(), dismiss: jest.fn() }
beforeEach(async () => {
documents.list.mockReset().mockReturnValue(of({ results: [] }))
documents.get.mockReset().mockReturnValue(of({ id: 1, page_count: 3 }))
templates.testZone.mockReset()
templates.create.mockReset()
templates.update.mockReset()
activeModal.close.mockReset()
await TestBed.configureTestingModule({
imports: [OcrTemplateEditorComponent],
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
NgxBootstrapIconsModule.pick(allIcons),
OcrTemplateEditorComponent,
],
providers: [
{ provide: NgbActiveModal, useValue: activeModal },
{ provide: DocumentService, useValue: documents },
{ provide: OcrTemplateService, useValue: templates },
...[CustomFieldsService, DocumentTypeService, CorrespondentService].map(
(provide) => ({
provide,
useValue: {
listAll: () => of({ results: [] }),
clearCache: jest.fn(),
},
})
),
{ provide: NgbModal, useValue: modal },
{
provide: ToastService,
useValue: {
showInfo: jest.fn(),
showSuccess: jest.fn(),
showError: jest.fn(),
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.overrideComponent(OcrTemplateEditorComponent, {
set: {
template: `
<span class="search-status">{{ searchStatus() }}</span>
<span class="document-type">{{ documentTypes()[0]?.name }}</span>
<span class="field">{{ fieldItems()[3]?.name }}</span>
<span class="preview-status">{{ previewStatus() }}</span>
<span class="preview-error">{{ previewError() }}</span>
<span class="page-count">{{ previewPageCount() }}</span>
<span class="preview-document">{{ documentFormatter(previewDocModel()) }}</span>
<button [disabled]="saving()">Save</button>
`,
imports: [],
},
})
.compileComponents()
jest
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
.mockReturnValue(of({ count: 2, all: [1, 2], results: customFields }))
jest
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
.mockReturnValue(of({ count: 0, all: [], results: [] }))
documentService = TestBed.inject(DocumentService)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ id: 5, page_count: 3, document_type: 2 }))
templateService = TestBed.inject(OcrTemplateService)
modalService = TestBed.inject(NgbModal)
activeModal.close.mockReset()
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
component = fixture.componentInstance
})
afterEach(() => jest.useRealTimers())
function prepareOverlay() {
const image = document.createElement('img')
Object.defineProperties(image, {
@@ -135,21 +113,15 @@ describe('OcrTemplateEditorComponent', () => {
})
component.imageRef = new ElementRef(image)
component.overlayRef = new ElementRef(overlay)
component.previewStatus.set('ready')
component.pageLoaded.set(true)
}
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('edits a copy of the supplied template and restores its sample document', () => {
it('should edit a copy of the template and load its sample document', () => {
const original: OcrTemplate = {
...component.template,
id: 3,
name: 'Invoices',
document_type: 1,
sample_document: 5,
zones: [zone()],
combine_formats: { '1': '{Invoice number}' },
@@ -157,184 +129,117 @@ describe('OcrTemplateEditorComponent', () => {
component.object = original
fixture.detectChanges()
expect(component.isNew).toBe(false)
expect(component.previewDocId).toBe(5)
expect(component.pageImageUrl).toBe('/preview/5/0')
expect(component.isDirty).toBe(false)
component.template.name = 'Edited'
expect(component.dialogTitle).toEqual('Edit OCR Template')
expect(component.previewDocId).toEqual(5)
expect(component.previewPageCount()).toEqual(3)
expect(component.pageImageUrl).toContain('document-page-image/5/0/')
expect(component.template.document_type).toEqual(1)
expect(component.isDirty).toBeFalsy()
component.template.zones[0].x = 500
component.template.combine_formats['1'] = 'Edited format'
expect(original.name).toBe('Invoices')
expect(original.zones[0].x).toBe(100)
expect(original.combine_formats['1']).toBe('{Invoice number}')
component.template.combine_formats['1'] = 'Edited'
expect(original.zones[0].x).toEqual(100)
expect(original.combine_formats['1']).toEqual('{Invoice number}')
})
it('updates an existing template, notifies the list, and closes the dialog', () => {
component.object = { ...component.template, id: 3, name: 'Invoices' }
fixture.detectChanges()
component.template.name = 'Renamed'
const saved = { ...component.template }
templates.update.mockReturnValue(of(saved))
const succeeded = jest.spyOn(component.succeeded, 'emit')
component.save()
expect(templates.update).toHaveBeenCalledWith(component.template)
expect(templates.create).not.toHaveBeenCalled()
expect(succeeded).toHaveBeenCalledWith(saved)
expect(activeModal.close).toHaveBeenCalled()
it('should fill the document type from a new sample document', () => {
component.setSampleDocument([5])
expect(component.template.document_type).toEqual(2)
})
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)
it('should keep only the last picked sample document', () => {
component.setSampleDocument([5, 6])
expect(component.sampleDocumentIds).toEqual([6])
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')
component.setSampleDocument([])
expect(component.previewDocId).toBeNull()
expect(component.pageImageUrl).toBeNull()
})
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.querySelector('.search-status').textContent
).toBe('empty')
})
it('renders asynchronously loaded metadata without manual change detection', async () => {
const types = new Subject<Results<DocumentType>>()
const fields = new Subject<Results<CustomField>>()
jest
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
.mockReturnValue(types)
jest
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
.mockReturnValue(fields)
fixture.detectChanges()
types.next({
count: 1,
all: [2],
results: [{ id: 2, name: 'Invoice' } as DocumentType],
})
fields.next({
count: 1,
all: [5],
results: [
{ id: 5, name: 'Total', data_type: CustomFieldDataType.Monetary },
],
})
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('.document-type').textContent
).toBe('Invoice')
expect(fixture.nativeElement.querySelector('.field').textContent).toBe(
'Total'
)
})
it('renders asynchronous preview metadata and errors without manual change detection', async () => {
fixture.detectChanges()
it('should ignore a stale sample document response', () => {
const pending = new Subject<Document>()
documents.get.mockReturnValue(pending)
component.previewDocId = 5
component.loadPreview()
pending.next({ id: 5, title: 'Invoice', page_count: 3 } as Document)
await fixture.whenStable()
jest.spyOn(documentService, 'get').mockReturnValueOnce(pending)
component.setSampleDocument([5])
component.setSampleDocument([6])
pending.next({ id: 5, page_count: 99 })
expect(fixture.nativeElement.querySelector('.page-count').textContent).toBe(
'3'
)
expect(
fixture.nativeElement.querySelector('.preview-document').textContent
).toBe('#5 Invoice')
pending.error(new Error('Offline'))
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('.preview-status').textContent
).toBe('error')
expect(
fixture.nativeElement.querySelector('.preview-error').textContent
).toBe('Could not load the selected document.')
expect(component.previewPageCount()).toEqual(3)
})
it('re-enables saving after an asynchronous failure without manual change detection', async () => {
fixture.detectChanges()
const pending = new Subject<OcrTemplate>()
templates.create.mockReturnValue(pending)
it('should show a failed page and retry it', () => {
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(throwError(() => new Error('offline')))
component.setSampleDocument([5])
expect(component.pageFailed()).toBeTruthy()
component.setSampleDocument(component.sampleDocumentIds)
expect(component.pageFailed()).toBeFalsy()
component.onImageError()
expect(component.pageFailed()).toBeTruthy()
})
it('should support create and update', () => {
const createSpy = jest
.spyOn(templateService, 'create')
.mockReturnValue(of({ ...component.template, id: 3 }))
const updateSpy = jest
.spyOn(templateService, 'update')
.mockReturnValue(of(component.template))
const succeededSpy = jest.spyOn(component.succeeded, 'emit')
component.save()
await fixture.whenStable()
expect(fixture.nativeElement.querySelector('button').disabled).toBe(true)
expect(createSpy).toHaveBeenCalled()
expect(succeededSpy).toHaveBeenCalledWith({ ...component.template, id: 3 })
expect(activeModal.close).toHaveBeenCalled()
component.saving.set(false)
component.template.id = 3
component.save()
expect(updateSpy).toHaveBeenCalledWith(component.template)
})
it('should emit failed and allow editing again after a failed save', () => {
prepareOverlay()
component.template.zones = [zone()]
const pending = new Subject<OcrTemplate>()
jest.spyOn(templateService, 'create').mockReturnValue(pending)
const failedSpy = jest.spyOn(component.failed, 'emit')
component.onOverlayPointerDown(pointer(150, 150))
component.save()
component.save()
expect(templateService.create).toHaveBeenCalledTimes(1)
component.onOverlayPointerMove(pointer(250, 250))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toEqual(100)
pending.error(new Error('Save failed'))
await fixture.whenStable()
expect(fixture.nativeElement.querySelector('button').disabled).toBe(false)
})
it('clears the selection on a click in empty space without adding a zone', () => {
prepareOverlay()
component.template.zones = [zone()]
component.selectZone(0)
component.onOverlayPointerDown(pointer(600, 600))
component.onOverlayPointerUp(pointer(602, 601))
expect(component.template.zones.length).toBe(1)
expect(component.selectedZoneIndex).toBeNull()
})
it('moves a zone when dragging on it instead of drawing', () => {
prepareOverlay()
component.template.zones = [zone()]
expect(failedSpy).toHaveBeenCalled()
expect(component.saving()).toBeFalsy()
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 200))
expect(component.template.zones.length).toBe(1)
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
expect(component.selectedZoneIndex).toBe(0)
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toEqual(200)
})
it('draws in empty space within the page bounds, using the release position', () => {
it('should track unsaved changes', () => {
fixture.detectChanges()
expect(component.isDirty).toBeFalsy()
component.template.combine_formats = { '1': '' }
expect(component.isDirty).toBeFalsy()
component.template.name = 'Invoices'
expect(component.isDirty).toBeTruthy()
component.template.name = ''
expect(component.isDirty).toBeFalsy()
component.setSampleDocument([5])
expect(component.isDirty).toBeTruthy()
})
it('should draw a zone with no field in empty space', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(1200, 250))
@@ -346,10 +251,31 @@ describe('OcrTemplateEditorComponent', () => {
height: 150,
custom_field: null,
})
expect(component.selectedZoneIndex).toBe(0)
expect(component.selectedZoneIndex).toEqual(0)
})
it('discards a drawing on Escape and ignores other pointers', () => {
it('should clear the selection on a click in empty space', () => {
prepareOverlay()
component.template.zones = [zone()]
component.selectZone(0)
component.onOverlayPointerDown(pointer(600, 600))
component.onOverlayPointerUp(pointer(602, 601))
expect(component.template.zones).toHaveLength(1)
expect(component.selectedZoneIndex).toBeNull()
})
it('should move a zone when dragging on it', () => {
prepareOverlay()
component.template.zones = [zone()]
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 200))
expect(component.template.zones).toHaveLength(1)
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
})
it('should cancel a drawing on escape and ignore other pointers', () => {
prepareOverlay()
component.onOverlayPointerDown(pointer(100, 100))
component.onOverlayPointerUp(pointer(300, 200, 2))
@@ -360,25 +286,32 @@ describe('OcrTemplateEditorComponent', () => {
expect(component.template.zones).toEqual([])
})
it('opens the zone in the Zones tab and returns to the list', () => {
it('should open a zone in the zones tab and go back to the list', () => {
component.template.zones = [zone(), zone()]
component.activeTab = 'settings'
component.selectZone(1)
expect(component.activeTab).toBe('zones')
expect(component.activeTab).toEqual('zones')
expect(component.selectedZone).toBe(component.template.zones[1])
component.deselectZone()
expect(component.selectedZone).toBeNull()
expect(component.activeTab).toBe('zones')
component.selectZone(0)
component.deleteSelectedZone()
expect(component.template.zones.length).toBe(1)
component.removeZone(0)
expect(component.template.zones).toHaveLength(1)
expect(component.selectedZone).toBeNull()
})
it('creates a custom field in the dialog and assigns it to the zone', () => {
it('should only offer custom fields OCR can write to', () => {
fixture.detectChanges()
expect(component.fieldItems().map((item) => item.id)).toEqual([
OcrZoneTarget.Title,
OcrZoneTarget.Asn,
OcrZoneTarget.Created,
1,
])
})
it('should create a custom field and assign it to the zone', () => {
const succeeded = new Subject<CustomField>()
const dialog = {
dialogMode: { set: jest.fn() },
@@ -386,251 +319,105 @@ describe('OcrTemplateEditorComponent', () => {
succeeded,
failed: new Subject(),
}
modal.open.mockReturnValue({ componentInstance: dialog })
const created = {
id: 5,
name: 'Total',
data_type: CustomFieldDataType.Monetary,
}
TestBed.inject(CustomFieldsService).listAll = () =>
of({
count: 2,
all: [5, 6],
results: [
created,
{ id: 6, name: 'Status', data_type: CustomFieldDataType.Select },
],
})
component.template.zones = [zone()]
jest
.spyOn(modalService, 'open')
.mockReturnValue({ componentInstance: dialog } as any)
component.template.zones = [zone({ custom_field: null })]
component.selectZone(0)
component.createField()
expect(dialog.object).toEqual({ name: 'Invoice number' })
succeeded.next(created)
succeeded.next(customFields[0])
expect(component.template.zones[0]).toMatchObject({
target: OcrZoneTarget.CustomField,
custom_field: 5,
})
expect(component.fieldItems().map((item) => item.id)).toEqual([
OcrZoneTarget.Title,
OcrZoneTarget.Asn,
OcrZoneTarget.Created,
5,
])
expect(component.template.zones[0].custom_field).toEqual(1)
})
it('builds a combine format for zones sharing a field and prunes stale ones', () => {
const first = { ...zone(), name: 'First' }
const second = { ...zone(), name: 'Second' }
const other = { ...zone(), name: 'Other', custom_field: 2 }
it('should build and prune combine formats', () => {
const first = zone({ name: 'First' })
const second = zone({ name: 'Second' })
const other = zone({ name: 'Other', custom_field: 2 })
component.template.zones = [first, second, other]
component.template.combine_formats = { '2': '{Other}' }
expect(component.isFieldShared(first)).toBe(true)
expect(component.isFieldShared(other)).toBe(false)
expect(component.defaultCombineFormat(first)).toBe('{First} {Second}')
expect(component.isFieldShared(first)).toBeTruthy()
expect(component.isFieldShared(other)).toBeFalsy()
expect(component.defaultCombineFormat(first)).toEqual('{First} {Second}')
component.insertCombineToken(first, second)
component.insertCombineToken(first, first)
expect(component.getCombineFormat(second)).toBe('{Second} {First}')
expect(component.getCombineFormat(second)).toEqual('{Second} {First}')
templates.create.mockReturnValue(of(component.template))
jest.spyOn(templateService, 'create').mockReturnValue(of(null))
component.save()
expect(component.template.combine_formats).toEqual({
'1': '{Second} {First}',
})
})
it("updates only exact tokens in the renamed zone's field format", () => {
const first = { ...zone(), name: 'First [value]' }
component.template.zones = [first, { ...zone(), name: 'Second' }]
it('should update exact tokens when a zone is renamed', () => {
const first = zone({ name: 'First [value]' })
component.template.zones = [first, zone({ name: 'Second' })]
component.template.combine_formats = {
'1': 'First [value]: {First [value]} / { First [value] } / {Second} / {First [value] extra}',
'2': '{First [value]}',
}
component.renameZone(first, '')
component.renameZone(first, 'Invoice number')
expect(first.name).toBe('Invoice number')
expect(first.name).toEqual('Invoice number')
expect(component.template.combine_formats).toEqual({
'1': 'First [value]: {Invoice number} / {Invoice number} / {Second} / {First [value] extra}',
'2': '{First [value]}',
})
})
it('keeps tokens in sync when a name is cleared and entered again', () => {
const first = { ...zone(), name: 'First' }
component.template.combine_formats = { '1': '{First} - {Second}' }
component.renameZone(first, '')
component.renameZone(first, 'Renamed')
expect(component.getCombineFormat(first)).toBe('{Renamed} - {Second}')
})
it('updates built-in field formats and leaves automatic combining implicit', () => {
const titleZone = {
...zone(),
name: 'First',
target: OcrZoneTarget.Title,
custom_field: null,
}
component.template.combine_formats = { title: '{First} / {Second}' }
component.renameZone(titleZone, 'Renamed')
expect(component.template.combine_formats.title).toBe(
'{Renamed} / {Second}'
)
const customZone = zone()
component.renameZone(customZone, 'New name')
expect(component.template.combine_formats['1']).toBeUndefined()
expect(customZone.name).toBe('New name')
})
it('tracks unsaved changes against the initial state', () => {
fixture.detectChanges()
expect(component.isDirty).toBe(false)
component.template.combine_formats = { '1': '' }
expect(component.isDirty).toBe(false)
component.template.name = 'Invoices'
expect(component.isDirty).toBe(true)
component.template.name = ''
expect(component.isDirty).toBe(false)
component.previewDocId = 5
expect(component.isDirty).toBe(true)
})
it('cancels drawing and prevents new gestures or duplicate saves while saving', () => {
prepareOverlay()
const pending = new Subject<any>()
templates.create.mockReturnValue(pending)
component.onOverlayPointerDown(pointer(500, 500))
component.save()
expect(component.saving()).toBe(true)
expect(component.drawingRect()).toBeNull()
component.save()
expect(templates.create).toHaveBeenCalledTimes(1)
component.onOverlayPointerDown(pointer(500, 500))
component.onOverlayPointerUp(pointer(700, 700))
expect(component.template.zones).toHaveLength(0)
const saved = { ...component.template, id: 3 }
const succeeded = jest.spyOn(component.succeeded, 'emit')
pending.next(saved)
pending.complete()
expect(succeeded).toHaveBeenCalledWith(saved)
expect(activeModal.close).toHaveBeenCalled()
})
it('stops an active move and unlocks editing without losing changes after a failed save', () => {
fixture.detectChanges()
prepareOverlay()
component.template.zones = [zone()]
const pending = new Subject<any>()
templates.create.mockReturnValue(pending)
component.onOverlayPointerDown(pointer(150, 150))
component.save()
component.onOverlayPointerMove(pointer(250, 250))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toBe(100)
pending.error(new Error('Save failed'))
expect(component.saving()).toBe(false)
expect(component.isDirty).toBe(true)
component.onOverlayPointerDown(pointer(150, 150))
component.onOverlayPointerUp(pointer(250, 250))
expect(component.template.zones[0].x).toBe(200)
})
it('keeps the language selection reference stable until its value changes', () => {
const selected = zone()
selected.ocr_language = 'deu+eng'
it('should keep the language array stable until the value changes', () => {
const selected = zone({ ocr_language: 'deu+eng' })
const languages = component.ocrLanguageArray(selected)
expect(languages).toEqual(['deu', 'eng'])
expect(component.ocrLanguageArray(selected)).toBe(languages)
component.setOcrLanguages(selected, ['fra'])
expect(selected.ocr_language).toBe('fra')
const updated = component.ocrLanguageArray(selected)
expect(updated).toEqual(['fra'])
expect(updated).not.toBe(languages)
expect(component.ocrLanguageArray(selected)).toBe(updated)
expect(selected.ocr_language).toEqual('fra')
expect(component.ocrLanguageArray(selected)).toEqual(['fra'])
component.setOcrLanguages(selected, null)
expect(selected.ocr_language).toBe('')
const empty = component.ocrLanguageArray(selected)
expect(empty).toEqual([])
expect(component.ocrLanguageArray(selected)).toBe(empty)
expect(component.ocrLanguageArray(selected)).toEqual([])
})
it('hides results when extraction settings or geometry change', () => {
it('should hide a test result once the zone changes', () => {
const selected = zone()
component.template.zones = [selected]
component.selectedZoneIndex = 0
component.previewDocId = 1
templates.testZone.mockReturnValue(of({ value: '123' }))
component.selectZone(0)
component.setSampleDocument([5])
jest
.spyOn(templateService, 'testZone')
.mockReturnValue(of({ value: '123' }))
component.testZone()
expect(component.zoneTestResult?.value).toBe('123')
expect(component.zoneTestResult.value).toEqual('123')
selected.transform = OcrZoneTransform.Numeric
expect(component.zoneTestResult).toBeNull()
component.testZone()
selected.width++
expect(component.zoneTestResult).toBeNull()
})
it('cancels a pending test when selection or sample document changes', () => {
it('should drop a pending test when the zone 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(0)
component.setSampleDocument([5])
jest.spyOn(templateService, 'testZone').mockReturnValue(pending)
component.testZone()
expect(component.zoneTesting).toBeTruthy()
component.selectZone(1)
pending.next({ value: 'Old zone' })
expect(component.zoneTesting).toBe(false)
expect(component.zoneTestResult).toBeNull()
component.testZone()
component.clearPreviewDoc()
component.setSampleDocument([])
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()
})
})
@@ -19,48 +19,30 @@ import {
NgbActiveModal,
NgbModal,
NgbNavModule,
NgbPopoverModule,
NgbTypeaheadModule,
NgbTypeaheadSelectItemEvent,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
Observable,
of,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { Subject, takeUntil } from 'rxjs'
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { CustomField } from 'src/app/data/custom-field'
import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import {
DATE_FORMAT_OPTIONS,
DEFAULT_OCR_ZONE_LANGUAGE,
getZoneFieldKey,
OCR_BUILTIN_TARGETS,
OCR_LANGUAGE_OPTIONS,
OCR_SUPPORTED_FIELD_TYPES,
OcrTemplate,
OcrTemplateZone,
OcrZoneTarget,
OcrZoneTransform,
OcrZoneTestResult,
TRANSFORM_OPTIONS,
ZoneTestRequest,
} 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'
@@ -100,12 +82,35 @@ type OverlayInteraction =
}
interface ZoneTest {
zone: OcrTemplateZone
documentId: number
request: ZoneTestRequest
/** The sample document and zone settings the result was produced from. */
key: string
result: OcrZoneTestResult | null
}
const CUSTOM_DATE_FORMAT_CHOICE = 'custom'
export const ZONE_TRANSFORM_OPTIONS = [
{ id: OcrZoneTransform.None, name: $localize`None` },
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
{
id: OcrZoneTransform.StripPunctuation,
name: $localize`Remove leading/trailing punctuation`,
},
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
]
export const ZONE_LANGUAGE_OPTIONS = [
{ id: 'eng', name: $localize`English` },
{ id: 'deu', name: $localize`German` },
{ id: 'fra', name: $localize`French` },
{ id: 'ita', name: $localize`Italian` },
{ id: 'spa', name: $localize`Spanish` },
{ id: 'por', name: $localize`Portuguese` },
{ id: 'nld', name: $localize`Dutch` },
]
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
nw: 'nw-resize',
@@ -120,9 +125,9 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
@Component({
selector: 'pngx-ocr-template-editor',
standalone: true,
imports: [
DragDropModule,
DocumentLinkComponent,
NumberComponent,
TextComponent,
SelectComponent,
@@ -130,8 +135,6 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
CommonModule,
FormsModule,
NgbNavModule,
NgbPopoverModule,
NgbTypeaheadModule,
NgxBootstrapIconsModule,
OcrTemplateEditorZoneListComponent,
],
@@ -139,21 +142,19 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
styleUrls: ['./ocr-template-editor.component.scss'],
})
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
readonly activeModal = inject(NgbActiveModal)
public readonly activeModal = inject(NgbActiveModal)
private readonly templateService = inject(OcrTemplateService)
private readonly customFieldsService = inject(CustomFieldsService)
private readonly documentTypeService = inject(DocumentTypeService)
private readonly correspondentService = inject(CorrespondentService)
private readonly documentService = inject(DocumentService)
private readonly toastService = inject(ToastService)
private readonly modalService = inject(NgbModal)
private readonly destroy$ = new Subject<void>()
private readonly cancelZoneTest$ = new Subject<void>()
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
@Input() object: OcrTemplate
@Output() succeeded = new EventEmitter<OcrTemplate>()
@Output() failed = new EventEmitter()
@Output() public readonly succeeded = new EventEmitter<OcrTemplate>()
@Output() public readonly failed = new EventEmitter()
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
imageRef: ElementRef<HTMLImageElement>
@@ -186,42 +187,47 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
zones: [],
}
readonly customFields = signal<CustomField[]>([])
readonly fieldItems = computed<{ id: ZoneFieldSelection; name: string }[]>(
() => [
...OCR_BUILTIN_TARGETS,
...this.customFields()
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
.map((field) => ({ id: field.id, name: field.name })),
]
)
readonly documentTypes = signal<DocumentType[]>([])
transformOptions = TRANSFORM_OPTIONS
ocrLanguageOptions = OCR_LANGUAGE_OPTIONS
readonly OcrZoneTransform = OcrZoneTransform
noSampleDocumentTitle = $localize`Select a sample document to test this zone`
dateFormatItems = [
...DATE_FORMAT_OPTIONS,
{ id: CUSTOM_DATE_FORMAT_CHOICE, name: $localize`Custom...` },
]
isNew = true
readonly saving = signal(false)
public readonly customFields = signal<CustomField[]>([])
public readonly fieldItems = computed<
{ id: ZoneFieldSelection; name: string }[]
>(() => [
...OCR_BUILTIN_TARGETS,
...this.customFields()
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
.map((field) => ({ id: field.id, name: field.name })),
])
public readonly documentTypes = signal<DocumentType[]>([])
public readonly OcrZoneTransform = OcrZoneTransform
public readonly saving = signal(false)
previewDocId: number | null = null
/** Bound to the document picker, which works with id arrays. */
sampleDocumentIds: number[] = []
previewPage = 0
readonly previewPageCount = signal<number | null>(null)
private pageCountForDoc: number | null = null
pageImageUrl: string | null = null
readonly previewStatus = signal<'empty' | 'loading' | 'ready' | 'error'>(
'empty'
)
readonly previewError = signal('')
readonly searchStatus = signal<
'idle' | 'loading' | 'ready' | 'empty' | 'error'
>('idle')
public readonly previewPageCount = signal<number | null>(null)
public readonly pageLoaded = signal(false)
public readonly pageFailed = signal(false)
zoom = 1
readonly previewDocModel = signal<Document | string>('')
private readonly correspondentNames = signal(new Map<number, string>())
get previewDocId(): number | null {
return this.sampleDocumentIds[0] ?? null
}
get pageImageUrl(): string | null {
return this.previewDocId
? this.templateService.getPageImageUrl(
this.previewDocId,
this.previewPage
)
: null
}
get transformOptions() {
return ZONE_TRANSFORM_OPTIONS
}
get languageOptions() {
return ZONE_LANGUAGE_OPTIONS
}
public get previewPageDisplay(): number {
return this.previewPage + 1
@@ -239,10 +245,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
private readonly zoneTest = signal<ZoneTest | null>(null)
get imageLoaded(): boolean {
return this.previewStatus() === 'ready'
}
get zoneTestResult(): OcrZoneTestResult | null {
return this.isCurrentZoneTest() ? this.zoneTest().result : null
}
@@ -275,9 +277,9 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
get dialogTitle(): string {
return this.isNew
? $localize`New OCR Template`
: $localize`Edit OCR Template`
return this.template.id
? $localize`Edit OCR Template`
: $localize`New OCR Template`
}
ngOnInit() {
@@ -295,131 +297,46 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
this.documentTypes.set(r.results)
})
this.correspondentService
.listAll()
.pipe(takeUntil(this.destroy$))
.subscribe((r) => {
this.correspondentNames.set(
new Map(r.results.map((c) => [c.id, c.name]))
)
})
if (this.object) {
this.isNew = false
this.template = {
...this.object,
zones: this.object.zones.map((zone) => ({ ...zone })),
combine_formats: { ...this.object.combine_formats },
}
if (this.template.sample_document) {
this.previewDocId = this.template.sample_document
this.loadPreview()
this.setSampleDocument([this.template.sample_document])
}
}
this.initialState = this.editorState()
}
searchDocuments = (text$: Observable<string>): Observable<Document[]> =>
text$.pipe(
map((term) => term.trim()),
debounceTime(250),
distinctUntilChanged(),
switchMap((term) => {
if (term.length < 2) {
this.searchStatus.set('idle')
return 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 | null): string => {
if (!doc) return ''
if (typeof doc === 'string') return doc
const corr = doc.correspondent
? this.correspondentNames().get(doc.correspondent)
: null
return corr
? `#${doc.id} ${doc.title} (${corr})`
: `#${doc.id} ${doc.title}`
}
onPreviewDocSelected(event: NgbTypeaheadSelectItemEvent<Document>) {
event.preventDefault()
const doc: Document = event.item
this.previewDocModel.set(doc)
this.searchStatus.set('idle')
this.previewDocId = doc.id
if (!this.template.document_type && doc.document_type) {
this.template.document_type = doc.document_type
}
setSampleDocument(ids: number[]) {
const id = ids.at(-1)
this.sampleDocumentIds = id ? [id] : []
this.previewPage = 0
this.loadPreview()
}
clearPreviewDoc() {
this.stopOverlayInteraction()
this.clearZoneTest()
this.previewDocModel.set('')
this.previewDocId = null
this.previewPageCount.set(null)
this.pageCountForDoc = null
this.previewPage = 0
this.pageImageUrl = null
this.previewStatus.set('empty')
this.previewError.set('')
this.searchStatus.set('idle')
this.resetPage()
if (!id) return
this.documentService
.get(id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (doc) => {
if (this.previewDocId !== id) return
this.previewPageCount.set(doc.page_count ?? null)
this.template.document_type ??= doc.document_type
},
error: () => {
if (this.previewDocId === id) this.pageFailed.set(true)
},
})
}
loadPreview() {
if (!this.previewDocId) return
private resetPage() {
this.stopOverlayInteraction()
this.clearZoneTest()
this.previewStatus.set('loading')
this.previewError.set('')
const documentId = this.previewDocId
if (this.pageCountForDoc !== documentId) {
this.pageCountForDoc = documentId
this.previewPageCount.set(null)
this.documentService
.get(documentId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (doc) => {
if (this.previewDocId !== documentId) return
this.previewPageCount.set(doc?.page_count ?? null)
if (doc && !this.previewDocModel()) this.previewDocModel.set(doc)
},
error: () => {
if (this.previewDocId !== documentId) return
this.previewPageCount.set(null)
this.pageCountForDoc = null
this.previewStatus.set('error')
this.previewError.set(
$localize`Could not load the selected document.`
)
},
})
}
this.pageImageUrl = this.templateService.getPageImageUrl(
this.previewDocId,
this.previewPage
)
this.pageLoaded.set(false)
this.pageFailed.set(false)
}
goToPage(page: number) {
@@ -429,7 +346,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
if (clamped === this.previewPage) return
this.previewPage = clamped
this.loadPreview()
this.resetPage()
}
zoomIn() {
@@ -461,8 +378,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
onImageLoad() {
if (this.previewStatus() !== 'loading') return
this.previewStatus.set('ready')
this.pageLoaded.set(true)
const img = this.imageRef.nativeElement
this.template.source_width = img.naturalWidth
this.template.source_height = img.naturalHeight
@@ -470,8 +386,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
onImageError() {
this.stopOverlayInteraction()
this.previewStatus.set('error')
this.previewError.set($localize`Could not load this page.`)
this.pageFailed.set(true)
}
@HostListener('document:keydown.escape')
@@ -483,11 +398,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
if (this.saving()) return
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
const point = this.svgPointFromEvent(event)
if (!this.imageLoaded || !point) return
if (!this.pageLoaded() || !point) return
event.preventDefault()
if (this.selectedZoneIndex !== null) {
const handle = this.findHandleAt(point, this.selectedZoneIndex)
const handle = this.handleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayInteraction = {
kind: 'resizing',
@@ -500,7 +415,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
}
const clickedIdx = this.findZoneAt(point)
const clickedIdx = this.zoneIndexAt(point)
if (clickedIdx !== null) {
this.selectZone(clickedIdx)
const zone = this.template.zones[clickedIdx]
@@ -546,10 +461,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
if (!point) return
if (this.overlayInteraction.kind === 'resizing') {
this.applyResize(
this.overlayInteraction.zoneIndex,
resizeZone(
this.template.zones[this.overlayInteraction.zoneIndex],
this.overlayInteraction.handle,
point
point,
this.imageNaturalSize()
)
return
}
@@ -575,13 +491,13 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
private updateOverlayCursor(point: Point) {
if (this.selectedZoneIndex !== null) {
const handle = this.findHandleAt(point, this.selectedZoneIndex)
const handle = this.handleAt(point, this.selectedZoneIndex)
if (handle) {
this.overlayCursor = RESIZE_CURSOR[handle]
return
}
}
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'crosshair'
this.overlayCursor = this.zoneIndexAt(point) !== null ? 'move' : 'crosshair'
}
onOverlayPointerUp(event: PointerEvent) {
@@ -649,31 +565,19 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
: null
}
zoneDisplayRect(zoneIdx: number): DisplayRect | null {
const img = this.imageRef?.nativeElement
if (!img || !img.naturalWidth) return null
const zone = this.template.zones[zoneIdx]
if (!zone) return null
/** The zone's rectangle on the overlay, or null if it's on another page. */
zoneDisplayRect(zoneIndex: number): DisplayRect | null {
const zone = this.template.zones[zoneIndex]
if (!this.isOnCurrentPage(zone)) return null
return getZoneDisplayRect(zone, this.imageNaturalSize())
}
private findHandleAt(point: Point, zoneIdx: number): ResizeHandle | null {
const r = this.zoneDisplayRect(zoneIdx)
if (!r) return null
return findHandleAt(point, r, this.overlayHandleSize())
private handleAt(point: Point, zoneIndex: number): ResizeHandle | null {
const rect = this.zoneDisplayRect(zoneIndex)
return rect ? findHandleAt(point, rect, this.overlayHandleSize()) : null
}
private applyResize(zoneIndex: number, handle: ResizeHandle, point: Point) {
const zone = this.template.zones[zoneIndex]
if (!zone) return
resizeZone(zone, handle, point, this.imageNaturalSize())
}
private findZoneAt(point: Point): number | null {
const img = this.imageRef.nativeElement
if (!img.naturalWidth) return null
private zoneIndexAt(point: Point): number | null {
return findZoneAt(
point,
this.template.zones,
@@ -688,7 +592,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
return `0 0 ${imageSize.width} ${imageSize.height}`
}
readonly zoneColor = getZoneColor
public readonly zoneColor = getZoneColor
zoneLabel(zone: OcrTemplateZone, index: number): string {
return zone.name || `Zone ${index + 1}`
@@ -698,7 +602,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
}
readonly resizeHandles = getResizeHandles
public readonly resizeHandles = getResizeHandles
overlayHandleSize(): number {
return this.overlayUnitSize(HANDLE_SIZE)
@@ -783,15 +687,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
const zone = this.selectedZone
if (!zone || !this.previewDocId) return
this.clearZoneTest()
const test: ZoneTest = {
zone,
documentId: this.previewDocId,
request: this.zoneTestRequest(zone),
result: null,
}
const test: ZoneTest = { zone, key: this.zoneTestKey(zone), result: null }
this.zoneTest.set(test)
this.templateService
.testZone(test.documentId, test.request)
.testZone(this.previewDocId, this.zoneTestRequest(zone))
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
.subscribe({
next: (res) => this.zoneTest.set({ ...test, result: res }),
@@ -808,21 +707,19 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
this.zoneTest.set(null)
}
/** A result is stale once its zone, settings or sample document change. */
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]
return (
test?.zone === this.selectedZone &&
test.key === this.zoneTestKey(test.zone)
)
}
private zoneTestKey(zone: OcrTemplateZone): string {
return JSON.stringify([this.previewDocId, this.zoneTestRequest(zone)])
}
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
return {
name: zone.name,
@@ -840,20 +737,15 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
}
deleteSelectedZone() {
if (this.selectedZoneIndex === null) return
this.removeZone(this.selectedZoneIndex)
}
save() {
if (this.saving()) return
this.stopOverlayInteraction()
this.saving.set(true)
this.pruneCombineFormats()
this.template.sample_document = this.previewDocId
const obs = this.isNew
? this.templateService.create(this.template)
: this.templateService.update(this.template)
const obs = this.template.id
? this.templateService.update(this.template)
: this.templateService.create(this.template)
obs.pipe(takeUntil(this.destroy$)).subscribe({
next: (saved) => {
@@ -867,6 +759,8 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
})
}
// ng-select needs a stable array between change detection runs, so the
// split language list is cached per zone until ocr_language changes.
private readonly ocrLanguageCache = new WeakMap<
OcrTemplateZone,
{ language: string; languages: string[] }
@@ -961,31 +855,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
}
/** Value bound to the date-format select: a preset, '' (auto), or 'custom'. */
dateFormatChoice(zone: OcrTemplateZone): string {
return this.usesCustomDateFormat(zone)
? CUSTOM_DATE_FORMAT_CHOICE
: zone.date_format || ''
}
setDateFormatChoice(zone: OcrTemplateZone, value: string) {
if (value === CUSTOM_DATE_FORMAT_CHOICE) {
this.customDateFormatZones.add(zone)
zone.date_format ||= ''
} else {
this.customDateFormatZones.delete(zone)
zone.date_format = value
}
}
usesCustomDateFormat(zone: OcrTemplateZone): boolean {
return (
this.customDateFormatZones.has(zone) ||
(!!zone.date_format &&
!DATE_FORMAT_OPTIONS.some((option) => option.id === zone.date_format))
)
}
createField(name?: string) {
const zone = this.selectedZone
const modal = this.modalService.open(CustomFieldEditDialogComponent)
@@ -1015,7 +884,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.previewResizeObserver?.disconnect()
this.clearZoneTest()
this.cancelZoneTest$.complete()
this.destroy$.next()
this.destroy$.complete()
@@ -31,14 +31,14 @@ export class OcrTemplatesComponent
extends LoadingComponentWithPermissions
implements OnInit
{
private ocrTemplateService = inject(OcrTemplateService)
private documentTypeService = inject(DocumentTypeService)
permissionsService = inject(PermissionsService)
private modalService = inject(NgbModal)
private dirtyFormGuard = inject(DirtyFormGuard)
private toastService = inject(ToastService)
private readonly ocrTemplateService = inject(OcrTemplateService)
private readonly documentTypeService = inject(DocumentTypeService)
public readonly permissionsService = inject(PermissionsService)
private readonly modalService = inject(NgbModal)
private readonly dirtyFormGuard = inject(DirtyFormGuard)
private readonly toastService = inject(ToastService)
readonly templates = signal<OcrTemplate[]>([])
public readonly templates = signal<OcrTemplate[]>([])
private readonly documentTypes = signal<DocumentType[]>([])
ngOnInit() {
-31
View File
@@ -64,37 +64,6 @@ export interface OcrTemplateZone {
zone_source_height?: number
}
export const TRANSFORM_OPTIONS = [
{ id: OcrZoneTransform.None, name: $localize`None` },
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
{
id: OcrZoneTransform.StripPunctuation,
name: $localize`Remove leading/trailing punctuation`,
},
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
]
export const OCR_LANGUAGE_OPTIONS = [
{ id: 'eng', name: $localize`English` },
{ id: 'deu', name: $localize`German` },
{ id: 'fra', name: $localize`French` },
{ id: 'ita', name: $localize`Italian` },
{ id: 'spa', name: $localize`Spanish` },
{ id: 'por', name: $localize`Portuguese` },
{ id: 'nld', name: $localize`Dutch` },
]
export const DATE_FORMAT_OPTIONS = [
{ id: '', name: $localize`Auto-detect` },
{ id: '%d.%m.%Y', name: 'DD.MM.YYYY' },
{ id: '%Y/%m/%d', name: 'YYYY/MM/DD' },
{ id: '%d/%m/%Y', name: 'DD/MM/YYYY' },
]
export interface OcrTemplate extends ObjectWithId {
name: string
document_type: number