mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 23:08:14 +00:00
Gosh so much cleanup
This commit is contained in:
7 files changed
+388
-824
No files matched your search
+3
-3
@@ -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
|
||||
|
||||
+31
-86
@@ -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>
|
||||
|
||||
-5
@@ -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;
|
||||
}
|
||||
|
||||
+216
-429
@@ -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()
|
||||
})
|
||||
})
|
||||
+131
-263
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user