feat(scan): whiten receipt backgrounds and embed OCR in PDFs

This commit is contained in:
2026-09-24 17:41:30 +02:00
parent b9cfdbe29e
commit a65bd72d74
26 changed files with 572 additions and 76 deletions

View File

@@ -52,11 +52,15 @@ for (const start of [false, true]) {
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
const realPython = (await execute('sh', ['-c', 'command -v python3'])).stdout.trim()
await writeFile(path.join(bin, 'python3'), '#!/bin/sh\nif [ "$1" = "-c" ] && [ "$2" = "import PIL, cv2, numpy" ]; then exit 0; fi\nexec "$FEDEO_TEST_PYTHON" "$@"\n', { mode: 0o755 })
await writeFile(path.join(bin, 'tesseract'), '#!/bin/sh\nprintf "deu\\neng\\n"\n', { mode: 0o755 })
const env = {
...process.env, PATH: bin + ':' + process.env.PATH,
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
FEDEO_TEST_PYTHON: realPython,
}
const scriptPath = path.join(temp, 'install.sh')
await writeFile(scriptPath, renderAgentInstaller(bundle, options))

View File

@@ -51,6 +51,7 @@ const setup = async (t: TestContext) => {
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng",
postprocessPython: "python3", postprocessStrict: false,
}
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
@@ -143,6 +144,18 @@ test("missing image runtime is detected before feeding paper", async (t) => {
await assert.rejects(args(), /ENOENT/)
})
test("missing requested OCR language fails before the scanner consumes paper", async (t) => {
const { config, job, dir, args } = await setup(t)
const tesseract = path.join(dir, "tesseract")
await writeFile(tesseract, "#!/bin/sh\nprintf 'List of available languages (1):\\neng\\n'\n")
await chmod(tesseract, 0o755)
job.settings!.ocr = true
job.settings!.ocrLanguage = "deu+eng"
await assert.rejects(runScan(config, job), /OCR-Sprachpakete fehlen: deu/)
await assert.rejects(args(), /ENOENT/)
await assert.rejects(readdir(config.workDir), /ENOENT/)
})
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
const { config, job, args } = await setup(t)
job.settings = { source: "Flatbed", format: "png" }

View File

@@ -0,0 +1,64 @@
import sys
import unittest
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from scan_background import whiten_background
class ScanBackgroundTest(unittest.TestCase):
def receipt(self, background=45, paper=245):
image = np.full((1600, 1200, 3), background, dtype=np.uint8)
image[120:1440, 360:820] = paper
for y in range(220, 1350, 70):
cv2.putText(image, "BON 12,34 EUR", (375, y), cv2.FONT_HERSHEY_SIMPLEX, .7, (20, 20, 20), 2)
cv2.rectangle(image, (400, 140), (780, 180), (40, 80, 210), -1)
return image
def test_receipt_on_dark_or_gray_backing_keeps_canvas_text_and_colored_logo(self):
for background in (35, 140):
image = self.receipt(background)
result = whiten_background(image)
self.assertEqual(result.shape, image.shape)
self.assertTrue(np.all(result[:80] == 255))
self.assertTrue(np.all(result[:, :250] == 255))
self.assertTrue(np.array_equal(result[120:1440, 360:820], image[120:1440, 360:820]))
def test_slanted_receipt_is_whitened_without_rotating_or_cropping(self):
image = np.full((1600, 1200, 3), 55, dtype=np.uint8)
polygon = np.array([[450, 100], [890, 155], [720, 1450], [280, 1390]])
cv2.fillPoly(image, [polygon], (245, 245, 245))
cv2.putText(image, "FEDEO", (470, 700), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 0), 3)
result = whiten_background(image)
self.assertEqual(result.shape, image.shape)
inside = np.zeros(image.shape[:2], dtype=np.uint8)
cv2.fillPoly(inside, [polygon], 255)
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
self.assertTrue(np.all(result[:50] == 255))
def test_white_full_page_is_not_replaced_by_its_largest_text_box(self):
image = np.full((1600, 1200, 3), 255, dtype=np.uint8)
cv2.rectangle(image, (300, 400), (800, 1200), (30, 30, 30), 4)
cv2.putText(image, "Footer", (20, 1550), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
self.assertTrue(np.array_equal(whiten_background(image), image))
def test_slanted_receipt_retains_every_paper_edge_pixel_at_300_dpi(self):
image = np.full((3508, 2480, 3), 45, dtype=np.uint8)
polygon = np.array([[820, 200], [1700, 340], [1250, 3300], [380, 3160]])
cv2.fillPoly(image, [polygon], (245, 245, 245))
inside = np.zeros(image.shape[:2], dtype=np.uint8)
cv2.fillPoly(inside, [polygon], 255)
result = whiten_background(image)
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
self.assertTrue(np.all(result[:50] == 255))
def test_blank_and_ambiguous_low_contrast_pages_remain_unchanged(self):
for image in [np.full((300, 200, 3), 245, dtype=np.uint8), self.receipt(215, 230)]:
self.assertTrue(np.array_equal(whiten_background(image), image))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw, ImageFont, PdfParser
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from scan_document import create_document, check_ocr
class ScanOcrTest(unittest.TestCase):
def test_missing_language_is_reported_before_processing(self):
with patch("scan_document.run_ocr_command", return_value="List of available languages (1):\neng\n"):
with self.assertRaisesRegex(RuntimeError, "deu"):
check_ocr("deu+eng")
with self.assertRaises(ValueError):
check_ocr("--bad-language")
@unittest.skipUnless(shutil.which("tesseract") and shutil.which("pdftotext"), "Tesseract and Poppler required")
def test_ocr_keeps_receipt_canvas_blank_back_and_page_order_with_real_extractable_text(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
pages = []
for index, text in enumerate(["FEDEO Bon 12345", "", "Zweite Seite 24680"]):
page = root / f"page-{index}.png"
image = Image.new("RGB", (1240, 1754), (45, 45, 45) if index == 0 else "white")
draw = ImageDraw.Draw(image)
if index == 0:
draw.rectangle((260, 150, 1000, 1550), fill="white")
if text:
draw.text((300, 300), text, fill="black", font=ImageFont.load_default(size=44))
draw.text((300, 390), "Summe EUR 12,34", fill="black", font=ImageFont.load_default(size=36))
image.save(page)
pages.append(page)
output = root / "searchable.pdf"
result = create_document(pages, output, 150, ocr=True, background_cleanup=True)
self.assertEqual(result["pageCount"], 3)
with PdfParser.PdfParser(filename=str(output)) as pdf:
self.assertEqual(len(pdf.pages), 3)
for reference in pdf.pages:
box = pdf.read_indirect(reference)[b"MediaBox"]
self.assertAlmostEqual(box[2], 1240 * 72 / 150, delta=.1)
self.assertAlmostEqual(box[3], 1754 * 72 / 150, delta=.1)
text = subprocess.check_output(["pdftotext", str(output), "-"], text=True)
extracted = text.split("\f")
self.assertIn("12345", extracted[0])
self.assertEqual(extracted[1].strip(), "")
self.assertIn("24680", extracted[2])
self.assertTrue(all(page.exists() for page in pages))
def test_ocr_failure_never_publishes_a_partial_document_or_removes_raw_pages(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
page = root / "raw.png"
Image.new("RGB", (400, 600), "white").save(page)
output = root / "failed.pdf"
with patch("scan_document.check_ocr"), patch("scan_document.run_ocr_command", side_effect=RuntimeError("OCR failed")):
with self.assertRaisesRegex(RuntimeError, "OCR failed"):
create_document([page], output, 300, ocr=True)
self.assertFalse(output.exists())
self.assertFalse(output.with_name("failed.pdf.partial").exists())
self.assertTrue(page.exists())
self.assertEqual(list(root.glob("prepared-*")), [])
if __name__ == "__main__":
unittest.main()