diff --git a/docs/cookbook.md b/docs/cookbook.md index b8d5c95c..34e9ae41 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -205,10 +205,45 @@ Backends that support area: `pdftotext` (native, uses `-x/-y/-W/-H`) and `input_module: pdftotext` at the top level (bundled area templates already do this). +## "An envelope identifies the supplier; the invoice is on later pages" + +Scope every text selector independently. This is the preferred form for a +multi-page document whose envelope, payment reference, invoice header and +line items live on different pages: + +```yaml +issuer: Example supplier +input_module: pdftotext +match: + pages: 1 + keywords: + - Example supplier +fields: + invoice_number: + parser: regex + pages: "2-3" + regex: 'Invoice number\\s+(\\S+)' + payment_reference: + parser: regex + pages: 1 + regex: 'Reference\\s+(\\S+)' + lines: + parser: lines + pages: "2-3" + line: '...' +``` + +`match.pages` only gates template selection. A field's `pages` limits only +that field, and combines with its `area` when both are set. This applies to +modern `fields:` parsers, including `fields.lines`. Camelot is already +page-aware through its own table setting. It is intentionally not attached to +static values or other settings that do not read document text. + ## "Only certain PDF pages are the invoice" -Use a top-level inclusive `pages:` range. Unlike a field `area:`, this limits -template keywords, document fields, field areas and line extraction together: +For an existing homogeneous template, the top-level inclusive `pages:` range +remains available as a compatibility shortcut. It limits template keywords, +document fields, field areas and line extraction together: ```yaml issuer: Example supplier diff --git a/src/invoice2data/api.py b/src/invoice2data/api.py index defc8960..69eb6c1e 100644 --- a/src/invoice2data/api.py +++ b/src/invoice2data/api.py @@ -285,10 +285,14 @@ def _match_template( def _match_template_for_reader( extracted_str: str, templates: list[InvoiceTemplate], invoicefile: str, reader: Any ) -> tuple[InvoiceTemplate | None, str]: - """Match templates, applying a template's optional page scope first.""" + """Match templates, applying the match selector's optional page scope.""" for template in templates: scoped_text = extracted_str - pages = template.get("pages") + match = template.get("match") + # ``pages`` at the template root is the pre-1.1 compatibility form. + # New templates make the matching scope explicit in ``match.pages``; + # their fields select their own text independently. + pages = match.get("pages") if isinstance(match, dict) else template.get("pages") if pages is not None: try: scoped_text = extract_text(reader, invoicefile, pages=pages) @@ -302,7 +306,10 @@ def _match_template_for_reader( ) continue if template.matches_input(scoped_text): - return template, scoped_text + # A selector-based template may match an envelope while extracting + # its invoice fields from later pages. Do not leak its match scope + # into those fields; each field owns its own optional ``pages``. + return template, extracted_str if isinstance(match, dict) else scoped_text return None, extracted_str diff --git a/src/invoice2data/extract/invoice_template.py b/src/invoice2data/extract/invoice_template.py index 9622f335..9ead9c1a 100644 --- a/src/invoice2data/extract/invoice_template.py +++ b/src/invoice2data/extract/invoice_template.py @@ -269,17 +269,24 @@ def extract( for k, v in self["fields"].items(): if isinstance(v, dict): + parser_settings = dict(v) + # A modern field is a text selector in its own right. Keep + # ``pages`` out of parser settings: it controls input text, + # rather than the regex/lines parser's behaviour. + pages = parser_settings.pop("pages", self.get("pages")) optimized_str_for_parser = _handle_area( self, - v, + parser_settings, input_module, invoice_file, optimized_str, - self.get("pages"), + pages, ) - if "parser" in v: - _handle_parser(self, k, v, optimized_str_for_parser, output) + if "parser" in parser_settings: + _handle_parser( + self, k, parser_settings, optimized_str_for_parser, output + ) elif k.startswith("static_"): logger.debug("field=%s | static value=%s", k, v) @@ -343,7 +350,7 @@ def _handle_area( optimized_str: str, pages: Any = None, ) -> str: - """Handle area-specific extraction.""" + """Select a field's optional page range and/or physical area.""" if "area" in v and supports_area(input_module): logger.debug(f"Area was specified with parameters {v['area']}") optimized_str_area: str = extract_text( @@ -355,6 +362,8 @@ def _handle_area( ) logger.debug("END pdftotext area result =============================") return optimized_str_area + if pages is not None: + return extract_text(input_module, invoice_file, pages=pages) return optimized_str diff --git a/src/invoice2data/extract/loader.py b/src/invoice2data/extract/loader.py index c686bad1..2528c7b3 100644 --- a/src/invoice2data/extract/loader.py +++ b/src/invoice2data/extract/loader.py @@ -217,6 +217,39 @@ def read_templates(folder: str | None = None) -> list[InvoiceTemplate]: return list(_read_templates_cached(folder, _folder_signature(folder))) +def _prepare_match_selector(tpl: dict[str, Any]) -> bool: + """Normalize an explicit ``match`` selector into legacy internal keys.""" + match = tpl.get("match") + if match is None: + return True + if not isinstance(match, dict): + logger.warning( + "Failed to load template %s. 'match' must be a mapping.", + tpl.get("template_name", ""), + ) + return False + if any(key in tpl for key in ("keywords", "exclude_keywords", "pages")): + logger.warning( + "Failed to load template %s. Put keywords, exclude_keywords and " + "pages inside 'match', not alongside it.", + tpl.get("template_name", ""), + ) + return False + if "keywords" not in match: + logger.warning( + "Failed to load template %s. 'match' is missing mandatory 'keywords'.", + tpl.get("template_name", ""), + ) + return False + # Keep the established internal representation. The nested selector is + # retained for its page scope while InvoiceTemplate continues to expose + # keywords at the top level to existing callers and plugins. + tpl["keywords"] = match["keywords"] + if "exclude_keywords" in match: + tpl["exclude_keywords"] = match["exclude_keywords"] + return True + + def prepare_template(tpl: dict[str, Any]) -> dict[str, Any] | None: """Prepare a template for use. @@ -226,6 +259,9 @@ def prepare_template(tpl: dict[str, Any]) -> dict[str, Any] | None: Returns: dict[str, Any] | None: Processed template dictionary. """ + if not _prepare_match_selector(tpl): + return None + # Test if all required fields are in template if "keywords" not in tpl: logger.warning( diff --git a/tests/test_page_ranges.py b/tests/test_page_ranges.py index 91d9795d..2e49c7a9 100644 --- a/tests/test_page_ranges.py +++ b/tests/test_page_ranges.py @@ -9,6 +9,7 @@ from invoice2data.api import _match_template_for_reader from invoice2data.extract.invoice_template import InvoiceTemplate +from invoice2data.extract.loader import prepare_template def _page_reader() -> types.ModuleType: @@ -143,3 +144,81 @@ def test_page_scoped_template_skips_unsupported_reader( assert selected is None assert "cannot use pages" in caplog.text + + +def test_selector_scoped_pages_can_match_and_extract_different_pages( + tmp_path: Path, +) -> None: + """Matching, scalar fields and modern lines each own their page scope.""" + pdf = tmp_path / "envelope-and-invoice.pdf" + pdf.write_bytes(b"%PDF-1.4") + reader = _page_reader() + + def to_text( + _path: str, + area_details: dict[str, Any] | None = None, + pages: tuple[int, int] | None = None, + ) -> str: + _ = area_details + content = { + None: "ENVELOPE SUPPLIER REF: G1\nINVOICE: INV-42\nLINE Widget", + (1, 1): "ENVELOPE SUPPLIER REF: G1", + (2, 3): "INVOICE: INV-42\nLINES START\nLINE Widget\nLINES END", + } + return content[pages] + + reader.to_text = to_text # type: ignore[attr-defined] + prepared = prepare_template( + { + "template_name": "supplier.yml", + "match": {"pages": 1, "keywords": ["ENVELOPE", "SUPPLIER"]}, + "required_fields": [], + "fields": { + "invoice_number": { + "parser": "regex", + "pages": "2-3", + "regex": r"INVOICE: (\S+)", + }, + "payment_reference": { + "parser": "regex", + "pages": 1, + "regex": r"REF: (\S+)", + }, + "lines": { + "parser": "lines", + "pages": "2-3", + "start": "LINES START", + "end": "LINES END", + "line": r"LINE (?P\w+)", + }, + }, + } + ) + assert prepared is not None + template = InvoiceTemplate(prepared) + + selected, document_text = _match_template_for_reader( + to_text(str(pdf)), [template], str(pdf), reader + ) + + assert selected is template + assert document_text.startswith("ENVELOPE") + result = template.extract(document_text, str(pdf), reader) + assert result["invoice_number"] == "INV-42" + assert result["payment_reference"] == "G1" + assert result["lines"] == [{"name": "Widget"}] + + +def test_match_selector_rejects_ambiguous_legacy_keywords() -> None: + """A template chooses either the explicit match selector or legacy keys.""" + assert ( + prepare_template( + { + "template_name": "ambiguous.yml", + "keywords": "legacy", + "pages": 1, + "match": {"keywords": "modern"}, + } + ) + is None + )