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
+2
View File
@@ -17,6 +17,8 @@ SOURCE_HASH="$(bash "$ROOT/native-engine/engine-fingerprint.sh")"
--add-data "personal-expense-template.xlsx:." engine.py
BINARY="$ROOT/native-engine/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper"
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_exports.py -k engine_process -v
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_ppt_portrait.py -k engine_process -v
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_approved_ppt.py -k engine_process -v
if [ "$SOURCE_HASH" != "$(bash "$ROOT/native-engine/engine-fingerprint.sh")" ]; then
echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2
exit 1
+1 -1
View File
@@ -93,7 +93,7 @@ def dispatch(request):
metadata = {}
try:
if operation in ('ppt', 'approved-ppt'):
export_ppt(state, temporary, request.get('classified', True))
metadata = export_ppt(state, temporary, request.get('classified', True))
elif operation == 'travel':
export_travel(state, temporary)
elif operation == 'expense':
+70 -72
View File
@@ -54,7 +54,7 @@ def export_approved_ppt(records, destination, classified):
if not match['invoices'] and not match['payments']:
raise ValueError(f"申请 #{record['id']} 没有发票或付款截图,无法按本地贴票格式导出,请取消选择该申请")
matches.append(match)
export_ppt(dict(matches=matches), destination, classified, preserve_order=True)
return export_ppt(dict(matches=matches), destination, classified, preserve_order=True)
def export_ppt(state, destination, classified, preserve_order=False):
@@ -64,92 +64,90 @@ def export_ppt(state, destination, classified, preserve_order=False):
from pptx import Presentation
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
presentation = Presentation()
presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720)
presentation.slide_width, presentation.slide_height = Pt(720), Pt(960)
def slide():
return presentation.slides.add_slide(presentation.slide_layouts[6])
page = presentation.slides.add_slide(presentation.slide_layouts[6])
page.background.fill.solid()
page.background.fill.fore_color.rgb = RGBColor(255, 255, 255)
return page
def picture(page, item, box):
image = image_for(item)
def picture(page, item, box, kind):
left, top, width, height = box
scale = min(width / image.width, height / image.height)
actual_width, actual_height = int(image.width * scale), int(image.height * scale)
content = io.BytesIO()
image.save(content, format='PNG')
content.seek(0)
page.shapes.add_picture(content, Pt(left + (width - actual_width) // 2), Pt(top + (height - actual_height) // 2), Pt(actual_width), Pt(actual_height))
with image_for(item) as image:
scale = min(width / image.width, height / image.height)
actual_width, actual_height = image.width * scale, image.height * scale
with io.BytesIO() as content:
image.save(content, format='PNG')
content.seek(0)
shape = page.shapes.add_picture(
content, Pt(left + (width - actual_width) / 2), Pt(top + (height - actual_height) / 2),
Pt(actual_width), Pt(actual_height)
)
shape.name = f'{kind}-{len(page.shapes)}'
def payment_page(payments):
page = slide()
height = 680
if len(payments) == 1:
picture(page, payments[0], (300, 20, 680, height))
else:
width = (1200 - 24 * (len(payments) - 1)) // len(payments)
for index, item in enumerate(payments):
picture(page, item, (40 + index * (width + 24), 20, width, height))
def label(page, box, value, size, title=False):
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(245, 248, 247) if title else RGBColor(255, 255, 255)
shape.line.color.rgb = RGBColor(216, 226, 222) if title else RGBColor(170, 188, 182)
shape.line.width = Pt(1 if title else 1.5)
shape.text = value
for paragraph in shape.text_frame.paragraphs:
def photo_hint(page, box):
shape = page.shapes.add_textbox(*(Pt(value) for value in box))
shape.name = 'manual-photo-area'
frame = shape.text_frame
frame.clear()
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
frame.word_wrap = True
frame.text = '实物照片粘贴区\n请手动粘贴,可删除此提示'
for paragraph in frame.paragraphs:
paragraph.alignment = PP_ALIGN.CENTER
for run in paragraph.runs:
run.font.name = 'PingFang SC'
run.font.size = Pt(size)
run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126)
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(155, 155, 155)
def photos(count):
for offset in range(0, count, 4):
slots = min(4, count - offset)
def combined_page(invoice, payments, inline_photo=False):
page = slide()
picture(page, invoice, (48, 44, 624, 360), 'invoice')
for index, item in enumerate(payments):
picture(page, item, (48 + index * 324, 444, 300, 468), 'payment')
if inline_photo:
photo_hint(page, (372, 444, 300, 468))
def payment_pages(payments):
for offset in range(0, len(payments), 6):
page = slide()
label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True)
for index in range(slots):
box = (250, 105, 780, 555) if slots == 1 else (55 + index * 610, 105, 560, 555) if slots == 2 else (55 + index % 2 * 610, 105 + index // 2 * 285, 560, 255)
label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16)
for index, item in enumerate(payments[offset:offset + 6]):
box = (48 + index % 3 * 216, 48 + index // 3 * 444, 192, 420)
picture(page, item, box, 'payment')
def requires_photo(match):
return match['category'].strip() not in ['交通', '住宿']
def simple_batch(batch):
if not batch:
return
for match in batch:
picture(slide(), match['invoices'][0], (40, 25, 1200, 670))
payment_page([match['payments'][0] for match in batch])
photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
matches = state['matches'] if preserve_order else sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
batch = []
matches = state['matches'] if preserve_order else sorted(
state['matches'],
key=lambda match: (
min((material_date(item, True) for item in match['invoices']), default='9999-12-31'),
match['category'] if classified else ''
)
)
if not matches:
raise ValueError('请先选择要导出的贴票材料')
for match in matches:
if batch and batch[-1].get('scope') != match.get('scope'):
simple_batch(batch)
batch = []
if len(match['invoices']) != 1 or len(match['payments']) != 1:
simple_batch(batch)
batch = []
for item in sorted(match['invoices'], key=material_date):
picture(slide(), item, (40, 25, 1200, 670))
payments = sorted(match['payments'], key=material_date)
for index in range(0, len(payments), 4):
payment_page(payments[index:index + 4])
if requires_photo(match):
photos(len(payments))
else:
batch.append(match)
if len(batch) == 2:
simple_batch(batch)
batch = []
simple_batch(batch)
invoices = sorted(match.get('invoices', []), key=material_date)
payments = sorted(match.get('payments', []), key=material_date)
if not invoices and not payments:
raise ValueError('所选组没有发票或付款截图,无法导出')
if len(invoices) == 1 and len(payments) == 1:
combined_page(invoices[0], payments, inline_photo=True)
continue
paired_count = len(invoices) - len(invoices) % 2
for offset in range(0, paired_count, 2):
page = slide()
picture(page, invoices[offset], (48, 44, 624, 408), 'invoice')
picture(page, invoices[offset + 1], (48, 508, 624, 408), 'invoice')
if len(invoices) % 2:
combined_page(invoices[-1], payments[:2])
payments = payments[2:]
payment_pages(payments)
photo_hint(slide(), (48, 48, 624, 864))
presentation.save(destination)
return dict(pptLayout='portrait-receipts-v1', pptSlideCount=len(presentation.slides))
def export_travel(state, destination):
+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()