diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.ts index c94bf0968..1ee4f90a2 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.ts @@ -18,10 +18,10 @@ export class OcrTemplateEditorZoneListComponent { @Input() zones: OcrTemplateZone[] = [] @Input() customFields: CustomField[] = [] - @Output() zoneSelected = new EventEmitter() - @Output() zoneRemoved = new EventEmitter() + @Output() public readonly zoneSelected = new EventEmitter() + @Output() public readonly zoneRemoved = new EventEmitter() - readonly zoneColor = getZoneColor + public readonly zoneColor = getZoneColor hasField(zone: OcrTemplateZone): boolean { return getZoneFieldKey(zone) !== null diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html index 24652fc99..b4369cd3a 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html @@ -35,7 +35,7 @@ - - @@ -117,7 +117,7 @@
Processing
@if (zone.transform === OcrZoneTransform.Date) { - - @if (usesCustomDateFormat(zone)) { -
- - -
- -

Python date codes:

-
    -
  • %d day (01-31)
  • -
  • %m month (01-12)
  • -
  • %Y year, 4-digit
  • -
  • %y year, 2-digit
  • -
  • %b month name (Jan)
  • -
- Example: %d.%m.%Y -> 03.03.2026 -
- } + placeholder="Auto-detect" + i18n-placeholder + hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically." + i18n-hint + > } @@ -176,62 +159,24 @@
- -
- - @if (searchStatus() === 'loading') { - Searching... - } @else { - - } - - - @if (previewDocId) { - - } -
- - {{ documentFormatter(document) }} - +
- @if (searchStatus() === 'empty' || searchStatus() === 'error') { -
- @if (searchStatus() === 'error') { - Document search failed. - } @else { - No matching documents. - } -
- }
-
- @if (previewStatus() === 'error') { +
+ @if (pageFailed()) { - } @else if (pageImageUrl) { -
+ } @else if (previewDocId) { +
- @if (imageLoaded) { + @if (pageLoaded()) { }
- @if (previewStatus() === 'loading') { -
+ @if (!pageLoaded()) { +
Loading page...
@@ -318,20 +263,20 @@
}
- @if (previewDocId && previewStatus() !== 'error') { + @if (previewDocId && !pageFailed()) {
- + of {{ previewPageCount() ?? '?' }}
- - - + + +
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss index f4822622b..eba0fe5ce 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss @@ -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; } diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts index a31a14e75..4be31cb29 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts @@ -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 { 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 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 + 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: ` - {{ searchStatus() }} - {{ documentTypes()[0]?.name }} - {{ fieldItems()[3]?.name }} - {{ previewStatus() }} - {{ previewError() }} - {{ previewPageCount() }} - {{ documentFormatter(previewDocModel()) }} - - `, - 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() - 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() - 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() - 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>() - const fields = new Subject>() - 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() - 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() - 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() + 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() 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() - 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() - 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() 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() - 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() - }) }) diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts index 7d37c5907..c7434876e 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts @@ -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 = { nw: 'nw-resize', @@ -120,9 +125,9 @@ const RESIZE_CURSOR: Record = { @Component({ selector: 'pngx-ocr-template-editor', - standalone: true, imports: [ DragDropModule, + DocumentLinkComponent, NumberComponent, TextComponent, SelectComponent, @@ -130,8 +135,6 @@ const RESIZE_CURSOR: Record = { CommonModule, FormsModule, NgbNavModule, - NgbPopoverModule, - NgbTypeaheadModule, NgxBootstrapIconsModule, OcrTemplateEditorZoneListComponent, ], @@ -139,21 +142,19 @@ const RESIZE_CURSOR: Record = { 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() private readonly cancelZoneTest$ = new Subject() - private readonly customDateFormatZones = new WeakSet() @Input() object: OcrTemplate - @Output() succeeded = new EventEmitter() - @Output() failed = new EventEmitter() + @Output() public readonly succeeded = new EventEmitter() + @Output() public readonly failed = new EventEmitter() @ViewChild('zoneOverlay') overlayRef: ElementRef imageRef: ElementRef @@ -186,42 +187,47 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { zones: [], } - readonly customFields = signal([]) - 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([]) - 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([]) + 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([]) + 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(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(null) + public readonly pageLoaded = signal(false) + public readonly pageFailed = signal(false) zoom = 1 - readonly previewDocModel = signal('') - private readonly correspondentNames = signal(new Map()) + + 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(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): Observable => - 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) { - 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() diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts index e4e052f57..4daaf1d31 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts @@ -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([]) + public readonly templates = signal([]) private readonly documentTypes = signal([]) ngOnInit() { diff --git a/src-ui/src/app/data/ocr-template.ts b/src-ui/src/app/data/ocr-template.ts index 1766c27f2..453647f3e 100644 --- a/src-ui/src/app/data/ocr-template.ts +++ b/src-ui/src/app/data/ocr-template.ts @@ -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