Cleanup the zone combine stuff

This commit is contained in:
shamoon committed 2026-10-19 22:55:23 -07:00
1 parent 68eb4fe96d
commit e028257da5
7 files changed
+99 -74

No files matched your search

@@ -7,7 +7,12 @@
<span class="p-2 rounded flex-shrink-0" [style.background-color]="zoneColor(i)" aria-hidden="true"></span>
<div class="flex-grow-1 text-truncate">
<button type="button" class="btn btn-link p-0 fw-semibold text-reset text-decoration-none text-truncate mw-100 stretched-link" (click)="zoneSelected.emit(i)">{{ zone.name }}</button>
<div class="small text-muted text-truncate">{{ getZoneTargetName(zone) }}</div>
<div class="small text-muted text-truncate">
{{ getZoneTargetName(zone) }}
@if (isCombined(zone)) {
<i-bs name="link" class="ms-1" title="Combined with other zones" i18n-title></i-bs>
}
</div>
</div>
<small class="text-muted text-nowrap">
@if (zone.page === -1) {
@@ -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')
@@ -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') {
@@ -88,43 +88,19 @@
></pngx-input-select>
@if (isFieldShared(zone)) {
<div class="card mb-3 border-info">
<div class="card-body">
<h6 class="card-title d-flex align-items-center gap-2">
<i-bs name="braces"></i-bs>
<span i18n>Combine zones into this field</span>
</h6>
<p class="small text-muted mb-2" i18n>
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.
</p>
<div class="d-flex flex-wrap gap-1 mb-2">
@for (z of zonesForField(zone); track $index) {
<button
type="button"
class="btn btn-sm btn-outline-info"
(click)="insertCombineToken(zone, z)"
title="Insert token"
i18n-title
>
+ {{ z.name || 'Zone' }}
</button>
}
</div>
<input
type="text"
class="form-control font-monospace"
[ngModel]="getCombineFormat(zone)"
(ngModelChange)="setCombineFormat(zone, $event)"
placeholder="{Zone 1} - {Zone 2}"
/>
<small class="text-muted" i18n>
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.
</small>
</div>
<pngx-input-text
[ngModel]="getCombineFormat(zone)"
(ngModelChange)="setCombineFormat(zone, $event)"
[placeholder]="defaultCombineFormat(zone)"
title="Combined value"
i18n-title
hint="Several zones write to this field. Set the format or leave empty to join them with spaces."
i18n-hint
></pngx-input-text>
<div class="d-flex flex-wrap gap-1 mt-n2 mb-3">
@for (z of zonesForField(zone); track z) {
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" (click)="insertCombineToken(zone, z)">{{ '{' + z.name + '}' }}</button>
}
</div>
}
@@ -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]
@@ -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<string, number>()
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]
}
}
}
+9
View File
@@ -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,