This commit is contained in:
csj
2026-09-16 21:02:44 +08:00
parent 7fea2063aa
commit 45b6f693d6
11 changed files with 320 additions and 86 deletions
+13 -4
View File
@@ -1,4 +1,5 @@
import copy
import io
import json
import os
import subprocess
@@ -65,7 +66,12 @@ class ApprovedPPTTests(unittest.TestCase):
second = Image.new('RGB', (300, 200), 'blue')
first.save(pdf, save_all=True, append_images=[second])
self.record['materials'] = [self.material('invoice', pdf)]
self.assertEqual(len(self.export([self.record]).slides), 1)
deck = self.export([self.record])
self.assertEqual(len(deck.slides), 2)
pictures = [shape for page in deck.slides for shape in page.shapes if shape.shape_type == 13]
self.assertEqual(len(pictures), 1)
with Image.open(io.BytesIO(pictures[0].image.blob)) as preview:
self.assertEqual(preview.convert('RGB').getpixel((0, 0)), (255, 255, 255))
def test_preserves_selected_order_without_merging_people(self):
other = copy.deepcopy(self.record)
@@ -79,7 +85,7 @@ class ApprovedPPTTests(unittest.TestCase):
self.assertEqual(deck.slides[0].shapes[0].image.blob, self.image.read_bytes())
self.assertEqual(deck.slides[2].shapes[0].image.blob, blue.read_bytes())
def test_no_added_metadata_and_same_simple_batch_layout(self):
def test_no_added_metadata_and_same_per_group_layout(self):
self.record['note'] = '采购备注' * 250
self.record['materials'] = [self.material('invoice'), self.material('payment')]
deck = self.export([self.record, copy.deepcopy(self.record)])
@@ -108,7 +114,7 @@ class ApprovedPPTTests(unittest.TestCase):
self.record.update(groupId=1, userId='1', materials=[self.material('invoice'), self.material('payment')])
other = copy.deepcopy(self.record)
other.update(userId='2')
self.assertEqual(len(self.export([self.record, other]).slides), 6)
self.assertEqual(len(self.export([self.record, other]).slides), 2)
def test_failure_keeps_existing_output(self):
destination = self.root / 'original.pptx'
@@ -135,7 +141,10 @@ class ApprovedPPTTests(unittest.TestCase):
events = [json.loads(line) for line in process.stdout.splitlines()]
self.assertEqual(events[-1]['event'], 'result')
self.assertEqual(events[-1]['result']['destination'], str(destination))
self.assertEqual(len(Presentation(destination).slides), 3)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 1)
self.assertEqual((deck.slide_width, deck.slide_height), (720 * 12700, 960 * 12700))
self.assertEqual(events[-1]['result']['pptLayout'], 'portrait-receipts-v1')
if __name__ == '__main__':
+34 -6
View File
@@ -34,21 +34,49 @@ class ExportTests(unittest.TestCase):
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 3)
self.assertEqual(deck.slide_width, 1280 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[2].shapes if shape.has_text_frame))
self.assertEqual(len(deck.slides), 1)
self.assertEqual(deck.slide_width, 720 * 12700)
self.assertEqual(deck.slide_height, 960 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[0].shapes if shape.has_text_frame))
def test_travel_category_excludes_photo(self):
def test_travel_category_also_reserves_photo_area(self):
self.state['matches'][0]['category'] = '交通'
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, False)
self.assertEqual(len(Presentation(destination).slides), 2)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 1)
self.assertTrue(any(shape.name == 'manual-photo-area' for shape in deck.slides[0].shapes))
def test_complex_payment_pagination(self):
self.state['matches'][0]['payments'] *= 5
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True)
self.assertEqual(len(Presentation(destination).slides), 5)
self.assertEqual(len(Presentation(destination).slides), 3)
def test_ppt_layouts_are_portrait_and_reserve_photo_page(self):
scenarios = []
single = copy.deepcopy(self.state['matches'][0])
scenarios.append(single)
one_invoice_many_payments = copy.deepcopy(single)
one_invoice_many_payments['payments'] *= 3
scenarios.append(one_invoice_many_payments)
many_invoices_many_payments = copy.deepcopy(single)
many_invoices_many_payments['invoices'] *= 2
many_invoices_many_payments['payments'] *= 2
scenarios.append(many_invoices_many_payments)
for index, match in enumerate(scenarios):
destination = self.root / f'layout-{index}.pptx'
export_ppt(dict(matches=[match]), destination, True)
deck = Presentation(destination)
self.assertEqual(deck.slide_width, 720 * 12700)
self.assertEqual(deck.slide_height, 960 * 12700)
self.assertGreaterEqual(len(deck.slides), 1)
self.assertTrue(any(
any('实物照片粘贴区' in shape.text for shape in slide.shapes if shape.has_text_frame)
for slide in deck.slides
))
self.assertEqual(sum(shape.name.startswith('payment-') for slide in deck.slides for shape in slide.shapes),
len(match['payments']))
def test_expense_template_values_and_formulas(self):
destination = self.root / 'expense.xlsx'
+169
View File
@@ -0,0 +1,169 @@
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()