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.html b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.html index ca18f77b7..2fe6926d9 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.html +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.html @@ -7,7 +7,12 @@
-
{{ getZoneTargetName(zone) }}
+
+ {{ getZoneTargetName(zone) }} + @if (isCombined(zone)) { + + } +
@if (zone.page === -1) { 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.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts index a9e41d1fe..687dcca02 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts @@ -56,6 +56,23 @@ describe('OcrTemplateEditorZoneListComponent', () => { expect(text).toContain('Last page') }) + it('marks zones that share a field', () => { + component.zones = [ + zone(), + zone({ name: 'Zone 2' }), + zone({ name: 'Zone 3', custom_field: 8 }), + ] + fixture.detectChanges() + + expect(component.isCombined(component.zones[0])).toBe(true) + expect(component.isCombined(component.zones[2])).toBe(false) + expect( + fixture.nativeElement.querySelectorAll( + '[title="Combined with other zones"]' + ).length + ).toBe(2) + }) + it('emits select and remove events', () => { component.zones = [zone()] const selectSpy = jest.spyOn(component.zoneSelected, 'emit') 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 9867e1d10..d373b1f49 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 @@ -1,7 +1,11 @@ import { Component, EventEmitter, Input, Output } from '@angular/core' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { CustomField } from 'src/app/data/custom-field' -import { OCR_BUILTIN_TARGETS, OcrTemplateZone } from 'src/app/data/ocr-template' +import { + getZoneFieldKey, + OCR_BUILTIN_TARGETS, + OcrTemplateZone, +} from 'src/app/data/ocr-template' import { getZoneColor } from '../zone-geometry' @Component({ @@ -18,6 +22,14 @@ export class OcrTemplateEditorZoneListComponent { readonly zoneColor = getZoneColor + isCombined(zone: OcrTemplateZone): boolean { + const key = getZoneFieldKey(zone) + return ( + key !== null && + this.zones.filter((z) => getZoneFieldKey(z) === key).length > 1 + ) + } + getZoneTargetName(zone: OcrTemplateZone): string { const target = zone.target || 'custom_field' if (target === 'custom_field') { 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 92ba66fb3..e94375dc6 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 @@ -88,43 +88,19 @@ > @if (isFieldShared(zone)) { -
-
-
- - Combine zones into this field -
-

- More than one zone writes to this field. Build the combined - value below: click a zone to insert its token, and type any - separators or literal text between tokens. -

-
- @for (z of zonesForField(zone); track $index) { - - } -
- - - Tokens are matched by zone name. An empty zone leaves its - token blank and the stray separator is trimmed. Leave empty - to just join the zones in order with a space. - -
+ +
+ @for (z of zonesForField(zone); track z) { + + }
} 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 132faa3ee..04d7abeef 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 @@ -39,6 +39,7 @@ describe('OcrTemplateEditorComponent', () => { } const templates = { testZone: jest.fn(), + create: jest.fn(), getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`, } const modal = { open: jest.fn() } @@ -268,6 +269,28 @@ describe('OcrTemplateEditorComponent', () => { ]) }) + 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 } + 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}') + + component.insertCombineToken(first, second) + component.insertCombineToken(first, first) + expect(component.getCombineFormat(second)).toBe('{Second} {First}') + + templates.create.mockReturnValue(of(component.template)) + component.save() + expect(component.template.combine_formats).toEqual({ + '1': '{Second} {First}', + }) + }) + it('hides results when extraction settings or geometry change', () => { const selected = zone() component.template.zones = [selected] 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 213432cdc..bf43f4700 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 @@ -47,6 +47,7 @@ import { DEFAULT_OCR_ZONE_LANGUAGE, DEFAULT_OCR_ZONE_TARGET, DEFAULT_OCR_ZONE_TRANSFORM, + getZoneFieldKey, isOcrBuiltinTarget, OCR_BUILTIN_TARGETS, OCR_LANGUAGE_OPTIONS, @@ -309,7 +310,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy$)) .subscribe((t) => { this.template = t - this.template.combine_formats ??= {} this.changeDetector.markForCheck() if (t.sample_document) { this.previewDocId = t.sample_document @@ -827,7 +827,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { this.clearZoneTest() const zone = this.template.zones[index] if (zone) { - this.seedCombineDefault(zone) this.goToPage(this.zonePage(zone) - 1) } } @@ -965,18 +964,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { zone.target = OCR_ZONE_TARGET.CustomField zone.custom_field = typeof value === 'number' ? value : null } - this.seedCombineDefault(zone) - } - - fieldKeyFor(zone: OcrTemplateZone): string | null { - const v = this.zoneFieldValue(zone) - return v === null || v === undefined ? null : String(v) } zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] { - const key = this.fieldKeyFor(zone) + const key = getZoneFieldKey(zone) if (!key) return [] - return this.template.zones.filter((z) => this.fieldKeyFor(z) === key) + return this.template.zones.filter((z) => getZoneFieldKey(z) === key) } isFieldShared(zone: OcrTemplateZone): boolean { @@ -984,47 +977,37 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } getCombineFormat(zone: OcrTemplateZone): string { - const key = this.fieldKeyFor(zone) - return (key && this.template.combine_formats?.[key]) || '' + return this.template.combine_formats?.[getZoneFieldKey(zone)] ?? '' } setCombineFormat(zone: OcrTemplateZone, value: string) { - const key = this.fieldKeyFor(zone) + const key = getZoneFieldKey(zone) if (!key) return this.template.combine_formats ??= {} this.template.combine_formats[key] = value } + defaultCombineFormat(zone: OcrTemplateZone): string { + return this.zonesForField(zone) + .map((z) => `{${z.name}}`) + .join(' ') + } + insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) { - const token = `{${tokenZone.name}}` const current = this.getCombineFormat(zone) const sep = current && !current.endsWith(' ') ? ' ' : '' - this.setCombineFormat(zone, `${current}${sep}${token}`) - } - - private seedCombineDefault(zone: OcrTemplateZone) { - const key = this.fieldKeyFor(zone) - if (!key) return - const shared = this.zonesForField(zone) - if (shared.length <= 1) return - this.template.combine_formats ??= {} - if (!this.template.combine_formats[key]) { - this.template.combine_formats[key] = shared - .map((z) => `{${z.name}}`) - .join(' ') - } + this.setCombineFormat(zone, `${current}${sep}{${tokenZone.name}}`) } + /** Drop formats that are empty or whose field no longer has several zones. */ private pruneCombineFormats() { const formats = this.template.combine_formats if (!formats) return - const counts = new Map() - for (const z of this.template.zones) { - const key = this.fieldKeyFor(z) - if (key) counts.set(key, (counts.get(key) ?? 0) + 1) - } - for (const key of Object.keys(formats)) { - if ((counts.get(key) ?? 0) <= 1) delete formats[key] + const keys = this.template.zones.map(getZoneFieldKey) + for (const [key, format] of Object.entries(formats)) { + if (!format || keys.filter((k) => k === key).length < 2) { + delete formats[key] + } } } diff --git a/src-ui/src/app/data/ocr-template.ts b/src-ui/src/app/data/ocr-template.ts index 6f2b9f1ab..5bc9cb8bb 100644 --- a/src-ui/src/app/data/ocr-template.ts +++ b/src-ui/src/app/data/ocr-template.ts @@ -43,6 +43,15 @@ export function isOcrBuiltinTarget(value: unknown): value is OcrBuiltinTarget { ) } +/** The field a zone writes to, keyed by OcrTemplate.combine_formats. */ +export function getZoneFieldKey(zone: OcrTemplateZone): string | null { + const target = zone.target || DEFAULT_OCR_ZONE_TARGET + if (target !== OCR_ZONE_TARGET.CustomField) return target + return zone.custom_field === null || zone.custom_field === undefined + ? null + : String(zone.custom_field) +} + /** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */ export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [ CustomFieldDataType.String,