170 lines
8.7 KiB
Python
170 lines
8.7 KiB
Python
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
from pptx import Presentation
|
|
from pptx.util import Pt
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from exports import export_ppt
|
|
|
|
|
|
def fixture(root, kind, index, size=None):
|
|
dimensions = size or ((1200, 700) if kind == 'invoice' else (360, 900))
|
|
path = root / f'{kind}-{index}.png'
|
|
image = Image.new('RGB', dimensions, 'white')
|
|
drawing = ImageDraw.Draw(image)
|
|
color = '#ab3d3d' if kind == 'invoice' else '#285b50'
|
|
drawing.rectangle((10, 10, dimensions[0] - 10, dimensions[1] - 10), outline=color, width=3)
|
|
drawing.text((30, 30), f'{kind.upper()} {index + 1}', fill=color, font_size=28)
|
|
if kind == 'invoice':
|
|
for fraction in [0.25, 0.4, 0.72, 0.82]:
|
|
height = int(dimensions[1] * fraction)
|
|
drawing.line((20, height, dimensions[0] - 20, height), fill=color, width=2)
|
|
drawing.text((40, dimensions[1] // 2), 'Receipt layout test - NOT A REAL INVOICE', fill=color, font_size=24)
|
|
else:
|
|
drawing.text((40, 130), 'PAYMENT RECEIPT', fill=color, font_size=22)
|
|
drawing.text((40, 240), f'Test amount: {index + 1}.00', fill=color, font_size=20)
|
|
for row in range(6):
|
|
drawing.text((40, 340 + row * 55), f'Test payment field {row + 1}', fill=color, font_size=16)
|
|
image.save(path)
|
|
return dict(id=f'{kind}-{index}', path=str(path), ocr=dict(rawText='', dates=['2026-09-16']))
|
|
|
|
|
|
class PortraitPPTTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temporary.name)
|
|
self.invoices = [fixture(self.root, 'invoice', index) for index in range(5)]
|
|
self.payments = [fixture(self.root, 'payment', index) for index in range(15)]
|
|
|
|
def tearDown(self):
|
|
self.temporary.cleanup()
|
|
|
|
def match(self, invoices, payments):
|
|
return dict(invoices=self.invoices[:invoices], payments=self.payments[:payments], category='交通')
|
|
|
|
def export(self, invoices, payments):
|
|
destination = self.root / 'portrait.pptx'
|
|
export_ppt(dict(matches=[self.match(invoices, payments)]), destination, True)
|
|
return Presentation(destination)
|
|
|
|
def pictures(self, slide, kind=None):
|
|
return [shape for shape in slide.shapes if shape.shape_type == 13 and
|
|
(kind is None or shape.name.startswith(kind + '-'))]
|
|
|
|
def assert_geometry_and_materials(self, deck, invoices, payments):
|
|
self.assertEqual((deck.slide_width, deck.slide_height), (Pt(720), Pt(960)))
|
|
expected = Counter(hashlib.sha256(Path(item['path']).read_bytes()).hexdigest()
|
|
for item in self.invoices[:invoices] + self.payments[:payments])
|
|
actual = Counter(hashlib.sha256(shape.image.blob).hexdigest()
|
|
for page in deck.slides for shape in self.pictures(page))
|
|
self.assertEqual(actual, expected)
|
|
for page in deck.slides:
|
|
invoice_shapes = self.pictures(page, 'invoice')
|
|
payment_shapes = self.pictures(page, 'payment')
|
|
self.assertLessEqual(len(invoice_shapes), 2)
|
|
self.assertLessEqual(len(payment_shapes), 6)
|
|
if len(invoice_shapes) == 2:
|
|
self.assertEqual(len(payment_shapes), 0)
|
|
self.assertLessEqual(invoice_shapes[0].top + invoice_shapes[0].height, invoice_shapes[1].top)
|
|
if len(invoice_shapes) == 1:
|
|
self.assertLessEqual(len(payment_shapes), 2)
|
|
for payment in payment_shapes:
|
|
self.assertLessEqual(invoice_shapes[0].top + invoice_shapes[0].height, payment.top)
|
|
for shape in page.shapes:
|
|
self.assertGreaterEqual(shape.left, 0)
|
|
self.assertGreaterEqual(shape.top, 0)
|
|
self.assertLessEqual(shape.left + shape.width, deck.slide_width)
|
|
self.assertLessEqual(shape.top + shape.height, deck.slide_height)
|
|
for index, shape in enumerate(page.shapes):
|
|
for other in list(page.shapes)[index + 1:]:
|
|
separated = (shape.left + shape.width <= other.left or other.left + other.width <= shape.left or
|
|
shape.top + shape.height <= other.top or other.top + other.height <= shape.top)
|
|
self.assertTrue(separated, (shape.name, other.name))
|
|
if shape.shape_type == 13:
|
|
image_width, image_height = shape.image.size
|
|
self.assertAlmostEqual(shape.width / shape.height, image_width / image_height, places=4)
|
|
self.assertEqual((shape.crop_left, shape.crop_right, shape.crop_top, shape.crop_bottom), (0, 0, 0, 0))
|
|
|
|
def test_single_pair_leaves_lower_right_photo_area(self):
|
|
deck = self.export(1, 1)
|
|
self.assertEqual(len(deck.slides), 1)
|
|
page = deck.slides[0]
|
|
payment = self.pictures(page, 'payment')[0]
|
|
photo = next(shape for shape in page.shapes if shape.name == 'manual-photo-area')
|
|
self.assertLessEqual(payment.left + payment.width, photo.left)
|
|
self.assert_geometry_and_materials(deck, 1, 1)
|
|
|
|
def test_single_invoice_eight_payments_matches_reference_pages(self):
|
|
deck = self.export(1, 8)
|
|
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [3, 6, 0])
|
|
grid = self.pictures(deck.slides[1])
|
|
self.assertEqual(len({shape.top for shape in grid}), 2)
|
|
self.assertEqual(len({shape.left for shape in grid}), 3)
|
|
self.assertEqual(deck.slides[2].shapes[0].name, 'manual-photo-area')
|
|
self.assert_geometry_and_materials(deck, 1, 8)
|
|
|
|
def test_three_invoices_two_payments_matches_reference_pages(self):
|
|
deck = self.export(3, 2)
|
|
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [2, 3, 0])
|
|
self.assertEqual(len(self.pictures(deck.slides[0], 'invoice')), 2)
|
|
self.assertEqual(len(self.pictures(deck.slides[1], 'invoice')), 1)
|
|
self.assert_geometry_and_materials(deck, 3, 2)
|
|
|
|
def test_all_counts_preserve_materials_without_overlap_or_cropping(self):
|
|
for invoices in range(6):
|
|
for payments in [0, 1, 2, 3, 5, 8, 9, 15]:
|
|
if not invoices and not payments:
|
|
continue
|
|
with self.subTest(invoices=invoices, payments=payments):
|
|
deck = self.export(invoices, payments)
|
|
self.assert_geometry_and_materials(deck, invoices, payments)
|
|
if (invoices, payments) != (1, 1):
|
|
self.assertEqual(len(self.pictures(deck.slides[-1])), 0)
|
|
self.assertEqual(deck.slides[-1].shapes[0].name, 'manual-photo-area')
|
|
|
|
def test_groups_are_not_mixed_and_no_photos_are_automatically_added(self):
|
|
destination = self.root / 'two-groups.pptx'
|
|
state = dict(matches=[self.match(1, 1), self.match(1, 1)],
|
|
photos=[fixture(self.root, 'photo', 0)])
|
|
export_ppt(state, destination, True)
|
|
deck = Presentation(destination)
|
|
self.assertEqual(len(deck.slides), 2)
|
|
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [2, 2])
|
|
|
|
def test_portrait_and_extreme_aspect_ratio_sources_stay_in_bounds(self):
|
|
self.invoices[0] = fixture(self.root, 'invoice', 0, (700, 1400))
|
|
self.payments[0] = fixture(self.root, 'payment', 0, (300, 2500))
|
|
self.assert_geometry_and_materials(self.export(1, 1), 1, 1)
|
|
|
|
def test_empty_group_fails_instead_of_silently_omitting_it(self):
|
|
with self.assertRaisesRegex(ValueError, '没有发票'):
|
|
self.export(0, 0)
|
|
|
|
def test_portrait_engine_process_protocol(self):
|
|
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
|
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
|
destination = self.root / 'process.pptx'
|
|
request = dict(operation='ppt', state=dict(matches=[self.match(1, 8), self.match(3, 2)]),
|
|
destination=str(destination))
|
|
process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(process.returncode, 0, process.stdout + process.stderr)
|
|
result = json.loads(process.stdout.splitlines()[-1])['result']
|
|
self.assertEqual(result['pptLayout'], 'portrait-receipts-v1')
|
|
deck = Presentation(destination)
|
|
self.assertEqual(result['pptSlideCount'], 6)
|
|
self.assertEqual((deck.slide_width, deck.slide_height), (Pt(720), Pt(960)))
|
|
self.assertEqual([len(self.pictures(page)) for page in deck.slides], [3, 6, 0, 2, 3, 0])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|