mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-02 17:12:18 +00:00
Fix: consolidate born-digital PDF detection between archive decision and OCR (#13409)
* Fix: unify born-digital PDF detection between archive decision and OCR should_produce_archive() and RasterisedDocumentParser.parse() each reimplemented the "does this PDF have real text" check independently, using different normalization of pdftotext output. Raw pdftotext output can be non-empty (whitespace/form-feed layout padding) even when there is no real content, so the two checks could disagree: consumer.py treated a tagged-but-textless PDF as born-digital and skipped the archive, while the parser's own (stricter, normalized) check found no text and ran OCR anyway, leaving the document with no archive despite real OCR text (GH #13387). Both call sites now share one predicate, pdf_born_digital_text() in paperless/parsers/utils.py, so they can no longer drift apart. * Fix: restore extract_text seam for born-digital detection in parse() parse() had switched to calling pdf_born_digital_text() directly for its initial text/born-digital check, bypassing the parser's own extract_text instance method. That broke test mockability (tests patch tesseract_parser.extract_text to control the born-digital decision) and caused CI failures with mismatched OCR call counts and text. Split pdf_born_digital_text() into is_born_digital_text(text, path, log) - a pure decision function - and a thin pdf_born_digital_text() wrapper for callers without text in hand (consumer.should_produce_archive). parse() now extracts via self.extract_text(None, document_path) and passes the result to is_born_digital_text(), restoring the seam with no change to production behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Cleanup: simplify born-digital detection, close #13387 test gap Simplification pass over the born-digital detection consolidation: - is_born_digital_text(): drop the has_text temp for an early return. - consumer.py: standardize the archive-decision log lines on plain hyphens (was a mix of em-dash and hyphen) and hoist the duplicated text_length computation. - Parametrize TestPdfBornDigitalText instead of four near-identical tests. Code review follow-up: the existing tests only ever exercised pdf_born_digital_text() through mocks, so the actual #13387 scenario (a tagged PDF whose only "text" is layout padding) was never checked against real pdftotext/pikepdf output - a regression in the normalize-before-decide logic itself would have gone undetected. Moved tagged_no_text_pdf_file from parsers/conftest.py up to the shared paperless/tests/conftest.py (it was previously only visible to tests under parsers/) and added a non-mocked regression test against the real sample file. Also fixed two stale comments in test_consumer.py referencing a _extract_text_for_archive_check helper that no longer exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
668fa77428
commit
350684cd6b
@@ -4,10 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from paperless.parsers.utils import is_tagged_pdf
|
||||
from paperless.parsers.utils import pdf_born_digital_text
|
||||
from paperless.parsers.utils import post_process_text
|
||||
from paperless.parsers.utils import read_file_handle_unicode_errors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
SAMPLES = Path(__file__).parent / "samples" / "tesseract"
|
||||
|
||||
|
||||
@@ -60,3 +68,105 @@ class TestIsTaggedPdf:
|
||||
bad = tmp_path / "bad.pdf"
|
||||
bad.write_bytes(b"not a pdf")
|
||||
assert is_tagged_pdf(bad) is False
|
||||
|
||||
|
||||
class TestPostProcessText:
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"simple string",
|
||||
"simple string",
|
||||
id="collapse-spaces",
|
||||
),
|
||||
pytest.param(
|
||||
"simple newline\n testing string",
|
||||
"simple newline\ntesting string",
|
||||
id="preserve-newline",
|
||||
),
|
||||
pytest.param(
|
||||
"utf-8 строка с пробелами в конце ", # noqa: RUF001
|
||||
"utf-8 строка с пробелами в конце", # noqa: RUF001
|
||||
id="utf8-trailing-spaces",
|
||||
),
|
||||
pytest.param(None, None, id="none-input"),
|
||||
pytest.param("", None, id="empty-string"),
|
||||
pytest.param(" \n\x0c \n ", None, id="whitespace-and-formfeed-only"),
|
||||
],
|
||||
)
|
||||
def test_post_process_text(
|
||||
self,
|
||||
source: str | None,
|
||||
expected: str | None,
|
||||
) -> None:
|
||||
assert post_process_text(source) == expected
|
||||
|
||||
|
||||
class TestPdfBornDigitalText:
|
||||
"""Regression coverage for GH #13387.
|
||||
|
||||
should_produce_archive() and RasterisedDocumentParser.parse() must agree
|
||||
on whether a PDF has real text, so both go through this one function.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extracted", "tagged", "expected_text", "expected_born_digital"),
|
||||
[
|
||||
pytest.param("tiny", True, "tiny", True, id="tagged-with-real-text"),
|
||||
pytest.param("tiny", False, "tiny", False, id="untagged-below-min-length"),
|
||||
pytest.param(
|
||||
"x" * 51,
|
||||
False,
|
||||
"x" * 51,
|
||||
True,
|
||||
id="untagged-above-min-length",
|
||||
),
|
||||
pytest.param(None, True, None, False, id="tagged-but-no-text"),
|
||||
],
|
||||
)
|
||||
def test_born_digital_decision(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
extracted: str | None,
|
||||
tagged: bool, # noqa: FBT001
|
||||
expected_text: str | None,
|
||||
expected_born_digital: bool, # noqa: FBT001
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A PDF whose pdftotext output and /MarkInfo tag status vary
|
||||
WHEN:
|
||||
- pdf_born_digital_text() is called
|
||||
THEN:
|
||||
- The normalized text and born-digital verdict match; the tag
|
||||
alone never counts as "has text"
|
||||
"""
|
||||
mocker.patch(
|
||||
"paperless.parsers.utils.extract_pdf_text",
|
||||
return_value=extracted,
|
||||
)
|
||||
mocker.patch("paperless.parsers.utils.is_tagged_pdf", return_value=tagged)
|
||||
text, born_digital = pdf_born_digital_text(tmp_path / "doc.pdf")
|
||||
assert text == expected_text
|
||||
assert born_digital is expected_born_digital
|
||||
|
||||
def test_tagged_but_textless_pdf_is_not_born_digital(
|
||||
self,
|
||||
tagged_no_text_pdf_file: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A real PDF that is tagged (/MarkInfo /Marked true) but whose
|
||||
only "text" is layout padding (a stray form-feed byte)
|
||||
WHEN:
|
||||
- pdf_born_digital_text() is called with no mocking
|
||||
THEN:
|
||||
- The normalized text is None and the PDF is not treated as
|
||||
born-digital. The raw, unnormalized pdftotext output is
|
||||
non-empty for this file, which is exactly what caused the
|
||||
archive decision to disagree with the OCR decision in #13387.
|
||||
"""
|
||||
text, born_digital = pdf_born_digital_text(tagged_no_text_pdf_file)
|
||||
assert text is None
|
||||
assert born_digital is False
|
||||
|
||||
Reference in New Issue
Block a user