@@ -130,9 +130,18 @@ pub const OCR_ACTION_NAMES: &[&str] = &[
130130 "render_tsv" ,
131131 "render_hocr" ,
132132 "render_searchable_pdf" ,
133+ // v2 (2026-07-10) — the structured-document + layout-classifier surface
134+ // the tesseract-rs arc shipped after the original eight. See
135+ // docs/OCR-ACTIONS-V2-PROPOSAL.md.
136+ "recognize_page_words" ,
137+ "recognize_document" ,
138+ "harvest_fields" ,
139+ "segment_page" ,
140+ "detect_halftone_regions" ,
141+ "detect_page_furniture" ,
133142] ;
134143
135- const _: ( ) = assert ! ( OCR_ACTION_NAMES . len( ) == 8 ) ;
144+ const _: ( ) = assert ! ( OCR_ACTION_NAMES . len( ) == 14 ) ;
136145
137146const RECOGNIZE_LINE_PARAMS : & [ OcrActionParam ] = & [
138147 OcrActionParam :: required ( "grey_line" ) ,
@@ -185,6 +194,75 @@ const RENDER_SEARCHABLE_PDF_PARAMS: &[OcrActionParam] = &[
185194] ;
186195const RENDER_SEARCHABLE_PDF_PRODUCES : & [ & str ] = & [ "pdf_bytes" ] ;
187196
197+ // ── v2 capability signatures (2026-07-10) ──────────────────────────────────
198+
199+ /// `recognize_page_words` — a full grey page recognized to WORD/box output
200+ /// (`LineWords`: per-line words each carrying char boxes + confidences),
201+ /// the word-level counterpart of `recognize_page`'s flat-text `textlines`.
202+ const RECOGNIZE_PAGE_WORDS_PARAMS : & [ OcrActionParam ] = & [
203+ OcrActionParam :: required ( "grey_page" ) ,
204+ OcrActionParam :: required ( "width" ) ,
205+ OcrActionParam :: required ( "height" ) ,
206+ OcrActionParam :: optional ( "with_dict" ) ,
207+ ] ;
208+ const RECOGNIZE_PAGE_WORDS_PRODUCES : & [ & str ] = & [ "line_words" ] ;
209+
210+ /// `recognize_document` — the ONE-SHOT: grey page in → `doc.v1` structured
211+ /// JSON (regions/lines/words with typed region classification) + a typed
212+ /// field harvest out. `harvest_profile` selects the field set (v2 vocabulary:
213+ /// `"german_invoice"`; absent = no harvest, empty `fields`; an unknown value
214+ /// is an executor-side FAIL, not a silent no-harvest).
215+ const RECOGNIZE_DOCUMENT_PARAMS : & [ OcrActionParam ] = & [
216+ OcrActionParam :: required ( "grey_page" ) ,
217+ OcrActionParam :: required ( "width" ) ,
218+ OcrActionParam :: required ( "height" ) ,
219+ OcrActionParam :: optional ( "with_dict" ) ,
220+ OcrActionParam :: optional ( "harvest_profile" ) ,
221+ ] ;
222+ const RECOGNIZE_DOCUMENT_PRODUCES : & [ & str ] = & [ "doc_json" , "fields" ] ;
223+
224+ /// `harvest_fields` — the typed field harvest over an already-recognized
225+ /// page's word output (numeric hardening + label-proximity + IBAN mod-97 +
226+ /// the netto+ust==brutto arithmetic cross-check). `harvest_profile` as above.
227+ const HARVEST_FIELDS_PARAMS : & [ OcrActionParam ] = & [
228+ OcrActionParam :: required ( "line_words" ) ,
229+ OcrActionParam :: required ( "page_w" ) ,
230+ OcrActionParam :: required ( "page_h" ) ,
231+ OcrActionParam :: required ( "harvest_profile" ) ,
232+ ] ;
233+ const HARVEST_FIELDS_PRODUCES : & [ & str ] = & [ "fields" ] ;
234+
235+ /// `segment_page` — recursive XY-cut layout segmentation (columns /
236+ /// deimposition) → reading-ordered `(l,t,r,b)` region rects.
237+ const SEGMENT_PAGE_PARAMS : & [ OcrActionParam ] = & [
238+ OcrActionParam :: required ( "grey_page" ) ,
239+ OcrActionParam :: required ( "width" ) ,
240+ OcrActionParam :: required ( "height" ) ,
241+ OcrActionParam :: optional ( "min_gap_frac" ) ,
242+ OcrActionParam :: optional ( "min_region_px" ) ,
243+ OcrActionParam :: optional ( "max_depth" ) ,
244+ ] ;
245+ const SEGMENT_PAGE_PRODUCES : & [ & str ] = & [ "regions_rects" ] ;
246+
247+ /// `detect_halftone_regions` — leptonica-parity `pixGenerateHalftoneMask`
248+ /// image-region detector over a BINARIZED page → figure component rects (+
249+ /// the mask dims and the found flag; the mask may be smaller than the page).
250+ const DETECT_HALFTONE_REGIONS_PARAMS : & [ OcrActionParam ] = & [
251+ OcrActionParam :: required ( "binary_page" ) ,
252+ OcrActionParam :: required ( "width" ) ,
253+ OcrActionParam :: required ( "height" ) ,
254+ ] ;
255+ const DETECT_HALFTONE_REGIONS_PRODUCES : & [ & str ] = & [ "figure_rects" , "mask_w" , "mask_h" , "found" ] ;
256+
257+ /// `detect_page_furniture` — header / footer / page-number detection over an
258+ /// already-recognized page's word output.
259+ const DETECT_PAGE_FURNITURE_PARAMS : & [ OcrActionParam ] = & [
260+ OcrActionParam :: required ( "line_words" ) ,
261+ OcrActionParam :: required ( "page_w" ) ,
262+ OcrActionParam :: required ( "page_h" ) ,
263+ ] ;
264+ const DETECT_PAGE_FURNITURE_PRODUCES : & [ & str ] = & [ "header_lines" , "footer_lines" , "page_number" ] ;
265+
188266/// Build one [`ActionDef`] for an OCR capability. `subject_concept` MUST
189267/// be a name minted in [`class_ids::ALL`] under the `0x08XX` (OCR) domain
190268/// — enforced by this module's tests, not by this constructor (the tables
@@ -230,6 +308,12 @@ fn ocr_action_def(
230308/// | `render_tsv` | `ocr_renderer` (`0x0809`) | `lines, page_w, page_h` | — | `tsv` |
231309/// | `render_hocr` | `ocr_renderer` (`0x0809`) | `lines, page_w, page_h, image_name` | — | `hocr` |
232310/// | `render_searchable_pdf` | `ocr_renderer` (`0x0809`) | `pages, dpi` | — | `pdf_bytes` |
311+ /// | `recognize_page_words` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict` | `line_words` |
312+ /// | `recognize_document` | `page_image` (`0x0808`) | `grey_page, width, height` | `with_dict, harvest_profile` | `doc_json, fields` |
313+ /// | `harvest_fields` | `page_layout` (`0x0807`) | `line_words, page_w, page_h, harvest_profile` | — | `fields` |
314+ /// | `segment_page` | `page_image` (`0x0808`) | `grey_page, width, height` | `min_gap_frac, min_region_px, max_depth` | `regions_rects` |
315+ /// | `detect_halftone_regions` | `page_image` (`0x0808`) | `binary_page, width, height` | — | `figure_rects, mask_w, mask_h, found` |
316+ /// | `detect_page_furniture` | `page_layout` (`0x0807`) | `line_words, page_w, page_h` | — | `header_lines, footer_lines, page_number` |
233317#[ must_use]
234318pub fn ocr_actions ( ) -> Vec < OcrActionSpec > {
235319 vec ! [
@@ -313,6 +397,67 @@ pub fn ocr_actions() -> Vec<OcrActionSpec> {
313397 params: RENDER_SEARCHABLE_PDF_PARAMS ,
314398 produces: RENDER_SEARCHABLE_PDF_PRODUCES ,
315399 } ,
400+ // ── v2 rows (2026-07-10) ──
401+ OcrActionSpec {
402+ def: ocr_action_def(
403+ "recognize_page_words" ,
404+ "page_image" ,
405+ RECOGNIZE_PAGE_WORDS_PARAMS ,
406+ RECOGNIZE_PAGE_WORDS_PRODUCES ,
407+ ) ,
408+ params: RECOGNIZE_PAGE_WORDS_PARAMS ,
409+ produces: RECOGNIZE_PAGE_WORDS_PRODUCES ,
410+ } ,
411+ OcrActionSpec {
412+ def: ocr_action_def(
413+ "recognize_document" ,
414+ "page_image" ,
415+ RECOGNIZE_DOCUMENT_PARAMS ,
416+ RECOGNIZE_DOCUMENT_PRODUCES ,
417+ ) ,
418+ params: RECOGNIZE_DOCUMENT_PARAMS ,
419+ produces: RECOGNIZE_DOCUMENT_PRODUCES ,
420+ } ,
421+ OcrActionSpec {
422+ def: ocr_action_def(
423+ "harvest_fields" ,
424+ "page_layout" ,
425+ HARVEST_FIELDS_PARAMS ,
426+ HARVEST_FIELDS_PRODUCES ,
427+ ) ,
428+ params: HARVEST_FIELDS_PARAMS ,
429+ produces: HARVEST_FIELDS_PRODUCES ,
430+ } ,
431+ OcrActionSpec {
432+ def: ocr_action_def(
433+ "segment_page" ,
434+ "page_image" ,
435+ SEGMENT_PAGE_PARAMS ,
436+ SEGMENT_PAGE_PRODUCES ,
437+ ) ,
438+ params: SEGMENT_PAGE_PARAMS ,
439+ produces: SEGMENT_PAGE_PRODUCES ,
440+ } ,
441+ OcrActionSpec {
442+ def: ocr_action_def(
443+ "detect_halftone_regions" ,
444+ "page_image" ,
445+ DETECT_HALFTONE_REGIONS_PARAMS ,
446+ DETECT_HALFTONE_REGIONS_PRODUCES ,
447+ ) ,
448+ params: DETECT_HALFTONE_REGIONS_PARAMS ,
449+ produces: DETECT_HALFTONE_REGIONS_PRODUCES ,
450+ } ,
451+ OcrActionSpec {
452+ def: ocr_action_def(
453+ "detect_page_furniture" ,
454+ "page_layout" ,
455+ DETECT_PAGE_FURNITURE_PARAMS ,
456+ DETECT_PAGE_FURNITURE_PRODUCES ,
457+ ) ,
458+ params: DETECT_PAGE_FURNITURE_PARAMS ,
459+ produces: DETECT_PAGE_FURNITURE_PRODUCES ,
460+ } ,
316461 ]
317462}
318463
@@ -323,10 +468,14 @@ pub const OCR_EXPECTED_EXECUTORS: &[&str] = &["tesseract-ogar"];
323468
324469/// The distinct subject classids this table binds (canon-high concept ids).
325470/// A registering consumer must activate exactly this set — verified via
326- /// [`crate::capability_registry::verify_registration`].
471+ /// [`crate::capability_registry::resolve_hotplug`] (the live hot-plug fuse;
472+ /// [`crate::capability_registry::verify_registration`] is the equivalent
473+ /// standalone check). `PAGE_LAYOUT` was added with the v2 rows
474+ /// (`harvest_fields` / `detect_page_furniture`, 2026-07-10).
327475pub const OCR_SUBJECT_CLASSIDS : & [ u16 ] = & [
328476 crate :: class_ids:: TEXTLINE ,
329477 crate :: class_ids:: PAGE_IMAGE ,
478+ crate :: class_ids:: PAGE_LAYOUT ,
330479 crate :: class_ids:: OCR_RENDERER ,
331480] ;
332481
@@ -442,4 +591,87 @@ mod tests {
442591 ) ;
443592 }
444593 }
594+
595+ /// v2: `recognize_document` is the one-shot composition of the word-level
596+ /// recognition — so its mandatory inputs must be a SUPERSET of
597+ /// `recognize_page_words`'s mandatory inputs (the one-shot cannot need
598+ /// less than the first stage it composes).
599+ #[ test]
600+ fn recognize_document_reads_cover_the_word_stage ( ) {
601+ let actions = ocr_actions ( ) ;
602+ let get = |name : & str | {
603+ actions
604+ . iter ( )
605+ . find ( |s| s. def . predicate == name)
606+ . unwrap_or_else ( || panic ! ( "missing capability {name}" ) )
607+ } ;
608+ let words: BTreeSet < & str > = get ( "recognize_page_words" )
609+ . params
610+ . iter ( )
611+ . filter ( |p| p. mandatory )
612+ . map ( |p| p. name )
613+ . collect ( ) ;
614+ let doc: BTreeSet < & str > = get ( "recognize_document" )
615+ . params
616+ . iter ( )
617+ . filter ( |p| p. mandatory )
618+ . map ( |p| p. name )
619+ . collect ( ) ;
620+ assert ! (
621+ words. is_subset( & doc) ,
622+ "recognize_document mandatory reads {doc:?} must cover recognize_page_words' {words:?}"
623+ ) ;
624+ }
625+
626+ /// v2: the `harvest_profile` vocabulary has exactly one documented value
627+ /// in v2 (`"german_invoice"`); pin it so a rename is a visible breaking
628+ /// change. `harvest_fields` requires the profile; `recognize_document`
629+ /// makes it optional (absent = no harvest).
630+ #[ test]
631+ fn harvest_profile_slot_is_present_where_documented ( ) {
632+ let actions = ocr_actions ( ) ;
633+ let harvest = actions
634+ . iter ( )
635+ . find ( |s| s. def . predicate == "harvest_fields" )
636+ . expect ( "harvest_fields present" ) ;
637+ assert ! (
638+ harvest
639+ . params
640+ . iter( )
641+ . any( |p| p. name == "harvest_profile" && p. mandatory) ,
642+ "harvest_fields must require harvest_profile"
643+ ) ;
644+ let doc = actions
645+ . iter ( )
646+ . find ( |s| s. def . predicate == "recognize_document" )
647+ . expect ( "recognize_document present" ) ;
648+ assert ! (
649+ doc. params
650+ . iter( )
651+ . any( |p| p. name == "harvest_profile" && !p. mandatory) ,
652+ "recognize_document must offer harvest_profile as optional"
653+ ) ;
654+ }
655+
656+ /// v2: the two `page_layout`-subject rows drove the single net-new entry
657+ /// in [`OCR_SUBJECT_CLASSIDS`]; assert the set is exactly the four minted
658+ /// concepts the 14 rows bind, no more, no less.
659+ #[ test]
660+ fn subject_classids_match_the_actual_row_subjects ( ) {
661+ let mut from_rows: BTreeSet < u16 > = BTreeSet :: new ( ) ;
662+ for spec in ocr_actions ( ) {
663+ let concept = subject_concept_of ( & spec. def ) ;
664+ let id = class_ids:: ALL
665+ . iter ( )
666+ . find ( |( name, _) | * name == concept)
667+ . expect ( "subject minted" )
668+ . 1 ;
669+ from_rows. insert ( id) ;
670+ }
671+ let declared: BTreeSet < u16 > = OCR_SUBJECT_CLASSIDS . iter ( ) . copied ( ) . collect ( ) ;
672+ assert_eq ! (
673+ from_rows, declared,
674+ "OCR_SUBJECT_CLASSIDS must equal the exact set of subjects the rows bind"
675+ ) ;
676+ }
445677}
0 commit comments