This commit is contained in:
csj
2026-09-18 10:35:38 +08:00
parent 45ccfd69be
commit 967eaaec8a
13 changed files with 787 additions and 463 deletions
+3
View File
@@ -87,6 +87,9 @@ def dispatch(request):
return scan(request)
if operation == 'inspect-expense-template':
return inspect_template(request['templatePath'])
if operation == 'preview-expense-template':
from spreadsheet_layout import read_layout
return read_layout(request['templatePath'])
if operation == 'validate-expense-template':
return validate_template(request['templatePath'], request['templateMapping'])
state = request['state']
+3 -2
View File
@@ -218,5 +218,6 @@ def prepare_preview(path):
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as output:
for name, data in template.parts.items():
output.writestr(name, data)
return dict(expensePreview='xlsx-layout-v2', previewUncalculatedCells=uncalculated,
previewLayout=read_layout(path))
result = dict(expensePreview='xlsx-layout-v4', previewUncalculatedCells=uncalculated,
previewLayout=read_layout(path))
return result
+125 -125
View File
@@ -1,131 +1,128 @@
"""Read-only display geometry from the exact XLSX that will be saved."""
import datetime
"""Read-only workbook display model; never modify the exported workbook."""
import math
import mimetypes
import re
from decimal import Decimal, ROUND_HALF_UP
# Match the travel exporter: the app sandbox cannot read system MIME databases.
# openpyxl otherwise reads system MIME files unavailable in the app sandbox.
mimetypes.knownfiles = []
mimetypes.init()
from openpyxl.styles.colors import COLOR_INDEX
from openpyxl.styles.numbers import BUILTIN_FORMATS, is_date_format
from openpyxl.utils.datetime import from_excel, MAC_EPOCH, WINDOWS_EPOCH
from expense_template import Template, column_name, coordinate, tag, xml
DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main'
MAX_CELLS = 50000
DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def flag(node, name, default=False):
return node.get(name, '1' if default else '0') in ('1', 'true')
def flag(node, key, default=False):
return node.get(key, '1' if default else '0') in ('1', 'true')
def child_value(node, name, fallback):
def child_value(node, name, default):
child = node.find(tag(name))
return child.get('val', fallback) if child is not None else fallback
return child.get('val', default) if child is not None else default
def theme_colors(template):
if 'xl/theme/theme1.xml' not in template.parts:
return ['FFFFFF', '000000', 'EEECE1', '1F497D', '4F81BD', 'C0504D',
defaults = ['FFFFFF', '000000', 'EEECE1', '1F497D', '4F81BD', 'C0504D',
'9BBB59', '8064A2', '4BACC6', 'F79646', '0000FF', '800080']
if 'xl/theme/theme1.xml' not in template.parts:
return defaults
scheme = xml(template.parts['xl/theme/theme1.xml']).find('.//{' + DRAWING + '}clrScheme')
by_name = {entry.tag.split('}')[-1]: entry[0].get('lastClr', entry[0].get('val', '000000'))
for entry in scheme}
return [by_name.get(name, '000000') for name in
['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4',
'accent5', 'accent6', 'hlink', 'folHlink']]
if scheme is None:
return defaults
colors = {entry.tag.split('}')[-1]: entry[0].get('lastClr', entry[0].get('val', '000000'))
for entry in scheme if len(entry)}
return [colors.get(name, defaults[i]) for i, name in enumerate(
['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4',
'accent5', 'accent6', 'hlink', 'folHlink'])]
def color(node, palette, fallback='000000'):
def color(node, palette, default='000000'):
if node is None:
return fallback
return default
value = node.get('rgb')
if value is None and 'theme' in node.attrib:
value = palette[int(node.get('theme')) % len(palette)]
if value is None and 'indexed' in node.attrib:
index = int(node.get('indexed'))
value = COLOR_INDEX[index] if index < len(COLOR_INDEX) else fallback
value = (value or fallback)[-6:]
value = COLOR_INDEX[index] if 0 <= index < len(COLOR_INDEX) else default
value = (value or default)[-6:]
if not re.fullmatch('[0-9a-fA-F]{6}', value):
return fallback
return default
tint = max(-1, min(1, float(node.get('tint', 0))))
return ''.join(f'{round(channel * (1 + tint) if tint < 0 else channel * (1 - tint) + 255 * tint):02X}'
for channel in (int(value[index:index + 2], 16) for index in (0, 2, 4)))
return ''.join(f'{round(c * (1 + tint) if tint < 0 else c * (1 - tint) + 255 * tint):02X}'
for c in (int(value[i:i + 2], 16) for i in (0, 2, 4)))
def literal(value):
return re.sub(r'"([^"]*)"|\\(.)|_.|\*.', lambda m: m[1] or m[2] or '', value)
def number_section(value, code):
sections = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', code)
return (sections[2] if value == 0 and len(sections) > 2 else
sections[1] if value < 0 and len(sections) > 1 else sections[0])
def formatted(value, kind, code, date_1904, warnings):
if value == '' or kind in ('s', 'inlineStr', 'str', 'e'):
if not value or kind in ('s', 'inlineStr', 'str', 'e'):
return value
if kind == 'b':
return 'TRUE' if value == '1' else 'FALSE'
number = Decimal(value)
if is_date_format(code):
date = from_excel(float(number), MAC_EPOCH if date_1904 else WINDOWS_EPOCH)
if isinstance(date, (datetime.datetime, datetime.time)):
# Excel date/time tokens are not strftime tokens (notably month/minute).
section = code.split(';')[0]
section = re.sub(r'\[[^\]]*\]', '', section)
section = literal(section)
if isinstance(date, datetime.datetime) and section.lower() == 'mm-dd-yy':
return date.strftime('%m-%d-%y')
if re.fullmatch(r'yyyy([/.-])m{1,2}\1d{1,2}', section, re.I):
sep = section[4]
return f'{date.year}{sep}{date.month:02d}{sep}{date.day:02d}' if 'mm' in section.lower() else f'{date.year}{sep}{date.month}{sep}{date.day}'
if section.lower() in ('yyyy""m""d""', 'yyyy年m月d日'):
return f'{date.year}{date.month}{date.day}'
if section.lower() in ('h:mm', 'hh:mm', 'h:mm:ss', 'hh:mm:ss'):
return date.strftime('%H:%M:%S' if 'ss' in section.lower() else '%H:%M')
warnings.add('部分日期格式使用标准日期显示')
return date.date().isoformat() if isinstance(date, datetime.datetime) else date.isoformat()
section = literal(re.sub(r'\[[^\]]*\]', '', code.split(';')[0])).lower()
if section == 'mm-dd-yy':
return date.strftime('%m/%d/%y')
if re.fullmatch(r'yyyy([/.-])m{1,2}\1d{1,2}', section):
return (str(date.year) + section[4] + (f'{date.month:02d}' if 'mm' in section else str(date.month))
+ section[4] + (f'{date.day:02d}' if 'dd' in section else str(date.day)))
if section == 'yyyy年m月d日':
return f'{date.year}{date.month}{date.day}'
if section in ('h:mm', 'hh:mm', 'h:mm:ss', 'hh:mm:ss'):
return date.strftime('%H:%M:%S' if 'ss' in section else '%H:%M')
warnings.add('特殊日期格式请在办公软件中核对')
return date.date().isoformat() if hasattr(date, 'date') else date.isoformat()
if code.lower() in ('general', '@'):
return format(number, 'f').rstrip('0').rstrip('.') if '.' in str(number) else str(number)
sections = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', code)
section = sections[2] if number == 0 and len(sections) > 2 else sections[1] if number < 0 and len(sections) > 1 else sections[0]
section = number_section(number, code)
section = re.sub(r'\[\$([^\]-]*)[^\]]*\]', lambda m: m[1], section)
if re.search(r'\[(?:[<>=]|\d)', section):
warnings.add('部分条件数字格式使用原始数值显示')
warnings.add('条件数字格式请在办公软件中核对')
return str(number)
section = re.sub(r'\[[^\]]*\]', '', section)
# Ignore placeholders inside quoted/escaped literals.
masked = re.sub(r'"[^"]*"|\\.|_.|\*.', lambda m: ' ' * len(m[0]), section)
match = re.search(r'[#0][#0,]*(?:\.[#0?]+)?%?', masked)
if not match:
return literal(section).strip().replace('?', '')
if re.search(r'[Ee][+-]|[/]', masked) or masked[match.end():].lstrip().startswith(','):
warnings.add('部分科学计数或分数格式使用原始数值显示')
warnings.add('特殊数字格式请在办公软件中核对')
return str(number)
pattern = match[0]
places = len(pattern.rstrip('%').split('.')[1]) if '.' in pattern else 0
value = abs(number) if number < 0 and len(sections) > 1 else number
fraction = pattern.rstrip('%').split('.')[1] if '.' in pattern else ''
number = abs(number) if number < 0 and len(code.split(';')) > 1 else number
if '%' in pattern:
value *= 100
value = value.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP)
text = format(value, (',' if ',' in pattern else '') + f'.{places}f')
if '.' in pattern:
fraction = pattern.rstrip('%').split('.')[1]
optional = len(fraction) - len(fraction.rstrip('#?'))
for _ in range(optional):
if text.endswith('0'):
text = text[:-1]
number *= 100
number = number.quantize(Decimal(1).scaleb(-len(fraction)), rounding=ROUND_HALF_UP)
text = format(number, (',' if ',' in pattern else '') + f'.{len(fraction)}f')
for _ in range(len(fraction) - len(fraction.rstrip('#?'))):
if text.endswith('0'):
text = text[:-1]
if fraction:
text = text.rstrip('.')
if ',' not in pattern and '.' not in pattern:
text = text.zfill(pattern.count('0'))
if '%' in pattern:
text += '%'
return literal(section[:match.start()]) + text + literal(section[match.end():])
return literal(section[:match.start()]) + text + ('%' if '%' in pattern else '') + literal(section[match.end():])
def read_styles(template, palette):
root = xml(template.parts['xl/styles.xml'])
fonts, fills, borders = root.find(tag('fonts')), root.find(tag('fills')), root.find(tag('borders'))
fonts, fills, borders = (root.find(tag(name)) for name in ('fonts', 'fills', 'borders'))
formats = dict(BUILTIN_FORMATS)
for item in root.findall(tag('numFmts') + '/' + tag('numFmt')):
formats[int(item.get('numFmtId'))] = item.get('formatCode')
@@ -153,93 +150,89 @@ def read_styles(template, palette):
horizontal=alignment.get('horizontal', 'general'), vertical=alignment.get('vertical', 'bottom'),
wrap=alignment.get('wrapText') in ('1', 'true'), shrink=alignment.get('shrinkToFit') in ('1', 'true'),
rotation=int(alignment.get('textRotation', 0)), indent=float(alignment.get('indent', 0)),
borders=sides, numberFormat=formats.get(int(entry.get('numFmtId', 0)), 'General')
))
borders=sides, numberFormat=formats.get(int(entry.get('numFmtId', 0)), 'General'),
accounting=False))
return styles
def read_layout(path):
template = Template(path)
palette = theme_colors(template)
styles = read_styles(template, palette)
styles = read_styles(template, theme_colors(template))
properties = template.workbook.find(tag('workbookPr'))
date_1904 = properties is not None and flag(properties, 'date1904')
warnings = set()
sheets, total_cells = [], 0
sheets, warnings, total_cells = [], set(), 0
for sheet_index, sheet in enumerate(template.sheets):
if sheet.get('state', 'visible') != 'visible':
continue
name = sheet.get('name')
_, _, document = template.sheet(name)
cells = {cell.get('r'): cell for cell in document.iter(tag('c'))}
merges = [item.get('ref') for item in document.iter(tag('mergeCell'))]
# Empty formatted tail columns must not squeeze the actual form. Preserve
# all nonempty cells, even when they are outside the workbook print area.
references = [ref for ref, cell in cells.items() if cell.find(tag('v')) is not None or
cell.find(tag('is')) is not None or cell.find(tag('f')) is not None]
ranges = []
cells = {c.get('r'): c for c in document.iter(tag('c'))}
merges = [m.get('ref') for m in document.iter(tag('mergeCell'))]
references = [ref for ref, c in cells.items() if any(c.find(tag(k)) is not None for k in ('v', 'is', 'f'))]
for definition in template.workbook.findall(tag('definedNames') + '/' + tag('definedName')):
if definition.get('name') == '_xlnm.Print_Area' and definition.get('localSheetId') == str(sheet_index):
ranges += re.findall(r'\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)', definition.text or '')
references += [f'{c}{r}' for a, b, c, r in ranges]
references += [entry.split(':')[-1] for entry in merges]
if not references:
references = list(cells) or ['A1']
positions = [coordinate(ref) for ref in references]
references += [c + r for _, _, c, r in re.findall(
r'\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)', definition.text or '')]
references += [m.split(':')[-1] for m in merges]
positions = [coordinate(ref) for ref in references or list(cells) or ['A1']]
columns, rows = max(p[0] for p in positions), max(p[1] for p in positions)
if columns * rows > MAX_CELLS or total_cells + len(cells) > MAX_CELLS:
total_cells += columns * rows
if total_cells > MAX_CELLS:
raise ValueError('模板范围过大,无法完整预览;请精简模板后重试')
total_cells += len(cells)
defaults = document.find(tag('sheetFormatPr'))
default_width = float(defaults.get('defaultColWidth', 8.43)) if defaults is not None else 8.43
default_height = float(defaults.get('defaultRowHeight', 15)) if defaults is not None else 15
widths = [default_width] * columns
heights = [default_height] * rows
dw = float(defaults.get('defaultColWidth', 8.43)) if defaults is not None else 8.43
dh = float(defaults.get('defaultRowHeight', 15)) if defaults is not None else 15
widths, heights = [dw] * columns, [dh] * rows
column_styles, row_styles = {}, {}
for column in document.findall(tag('cols') + '/' + tag('col')):
for index in range(int(column.get('min')) - 1, min(columns, int(column.get('max')))):
widths[index] = 0 if flag(column, 'hidden') else float(column.get('width', default_width))
for index in range(max(0, int(column.get('min')) - 1), min(columns, int(column.get('max')))):
widths[index] = 0 if flag(column, 'hidden') else float(column.get('width', dw))
column_styles[index + 1] = int(column.get('style', 0))
for row in document.findall(tag('sheetData') + '/' + tag('row')):
index = int(row.get('r')) - 1
if index < rows:
heights[index] = 0 if flag(row, 'hidden') else float(row.get('ht', default_height))
# OOXML width is expressed in maximum-digit units. Geometry is kept at
# 96 dpi; Swift scales the complete sheet, never individual columns.
widths = [math.floor(((256 * width + 18) / 256) * 7) for width in widths]
heights = [height * 4 / 3 for height in heights]
if 0 <= index < rows:
heights[index] = 0 if flag(row, 'hidden') else float(row.get('ht', dh))
if flag(row, 'customFormat'):
row_styles[index + 1] = int(row.get('s', 0))
xs, ys = [0], [0]
for width in widths:
xs.append(xs[-1] + width)
xs.append(xs[-1] + math.floor(((256 * width + 18) / 256) * 7))
for height in heights:
ys.append(ys[-1] + height)
ys.append(ys[-1] + height * 4 / 3)
covered, spans = set(), {}
for merged in merges:
first, last = merged.split(':')
left, top = coordinate(first)
right, bottom = coordinate(last)
spans[first] = (right, bottom)
covered.update((col, row) for col in range(left, right + 1) for row in range(top, bottom + 1)
if (col, row) != (left, top))
output = []
occupied = {coordinate(ref) for ref, cell in cells.items()
if cell.find(tag('v')) is not None or cell.find(tag('is')) is not None
or cell.find(tag('f')) is not None}
covered.update((c, r) for c in range(left, right + 1) for r in range(top, bottom + 1)
if (c, r) != (left, top))
def style_at(ref):
col, row = coordinate(ref)
cell = cells.get(ref)
return styles[int(cell.get('s', row_styles.get(row, column_styles.get(col, 0)))) if cell is not None
else row_styles.get(row, column_styles.get(col, 0))]
occupied = {coordinate(ref) for ref, c in cells.items()
if any(c.find(tag(k)) is not None for k in ('v', 'is', 'f'))}
occupied.update(covered)
occupied.update(coordinate(ref) for ref in spans)
output = []
for reference, cell in cells.items():
col, row = coordinate(reference)
if col > columns or row > rows or (col, row) in covered:
continue
right, bottom = spans.get(reference, (col, row))
style = int(cell.get('s', 0))
appearance = dict(styles[style])
appearance = dict(style_at(reference))
appearance['borders'] = dict(appearance['borders'])
# Merged right/bottom edges may be stored on the last cell.
for edge, edge_ref in [('right', column_name(right) + str(row)),
('bottom', column_name(col) + str(bottom)),
('right', column_name(right) + str(bottom)),
('bottom', column_name(right) + str(bottom))]:
if edge not in appearance['borders'] and edge_ref in cells:
candidate = styles[int(cells[edge_ref].get('s', 0))]['borders'].get(edge)
if reference in spans:
# Use the perimeter, not the anchor's former internal edges.
for edge, edge_ref in [('right', column_name(right) + str(row)),
('bottom', column_name(col) + str(bottom)),
('right', column_name(right) + str(bottom)),
('bottom', column_name(right) + str(bottom))]:
candidate = style_at(edge_ref)['borders'].get(edge)
if candidate:
appearance['borders'][edge] = candidate
kind = cell.get('t', 'n')
@@ -247,26 +240,33 @@ def read_layout(path):
if kind == 's':
value = template.shared[int(value)] if value else ''
elif kind == 'inlineStr':
value = ''.join(node.text or '' for node in cell.iter(tag('t')))
value = ''.join(t.text or '' for t in cell.iter(tag('t')))
text = formatted(value, kind, appearance['numberFormat'], date_1904, warnings)
if kind == 'n' and value:
section = number_section(Decimal(value), appearance['numberFormat'])
# Asterisk-space pads the value to the right even when the cell
# alignment is centered. Do not discard that layout instruction.
masked = re.sub(r'"[^"]*"|\\.', '', section)
appearance['accounting'] = '* ' in masked
if appearance['horizontal'] == 'general':
appearance['horizontal'] = 'right' if kind == 'n' else 'center' if kind == 'b' else 'left'
width, height = xs[right] - xs[col - 1], ys[bottom] - ys[row - 1]
if width > 0 and height > 0:
left_limit, right_limit = col, right
if (text and kind in ('s', 'inlineStr', 'str') and reference not in spans
and not appearance['wrap'] and not appearance['shrink'] and appearance['rotation'] == 0):
if appearance['horizontal'] in ('left', 'center'):
while right_limit < columns and (right_limit + 1, row) not in occupied:
right_limit += 1
if appearance['horizontal'] in ('right', 'center'):
while left_limit > 1 and (left_limit - 1, row) not in occupied:
left_limit -= 1
output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height,
text=text, style=appearance,
overflowLeft=xs[left_limit - 1], overflowRight=xs[right_limit]))
if width <= 0 or height <= 0:
continue
left_limit, right_limit = col, right
if (text and kind in ('s', 'inlineStr', 'str') and reference not in spans
and not appearance['wrap'] and not appearance['shrink'] and appearance['rotation'] == 0):
if appearance['horizontal'] in ('left', 'center'):
while right_limit < columns and (right_limit + 1, row) not in occupied:
right_limit += 1
if appearance['horizontal'] in ('right', 'center'):
while left_limit > 1 and (left_limit - 1, row) not in occupied:
left_limit -= 1
output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height,
text=text, style=appearance, overflowLeft=xs[left_limit - 1],
overflowRight=xs[right_limit]))
for feature in ('drawing', 'legacyDrawing', 'conditionalFormatting'):
if document.find(tag(feature)) is not None:
warnings.add('模板中的图片、图表或条件格式请在 Excel / WPS 中核对')
sheets.append(dict(name=name, width=max(1, xs[-1]), height=max(1, ys[-1]), cells=output))
return dict(version=1, sheets=sheets, warnings=sorted(warnings))
return dict(version=4, sheets=sheets, warnings=sorted(warnings))
+58 -3
View File
@@ -12,7 +12,7 @@ from openpyxl import load_workbook
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_expense_template as fixtures
from expense_preview import PreviewCalculator, UnsupportedFormula, builtin_rmb, prepare_preview
from expense_template import Template, encoded, set_cell, tag
from expense_template import Template, encoded, export_custom_expense, set_cell, tag
class ExpensePreviewTests(unittest.TestCase):
@@ -36,10 +36,48 @@ class ExpensePreviewTests(unittest.TestCase):
self.assertEqual(formulas.active['H29'].value, '=IF(F27>C27,F27-C27,0)')
self.assertEqual(result['previewUncalculatedCells'], [])
self.assertEqual(sheet['D29'].value, '伍佰肆拾肆元叁角整')
self.assertEqual(result['expensePreview'], 'xlsx-layout-v2')
self.assertEqual(result['expensePreview'], 'xlsx-layout-v4')
values.close()
formulas.close()
def test_original_preview_preserves_signature_borders_fonts_and_geometry(self):
signatures = ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人']
payee = dict(recipient='测试收款人', bankName='测试银行', accountNumber='001234567890')
source = self.fixture.root / 'signature-template.xlsx'
output = self.fixture.root / 'signature-preview.xlsx'
export_custom_expense(self.fixture.state, source, self.fixture.template, self.fixture.mapping,
{}, signatures, payee, {})
prepare_preview(source)
changed = dict(matches=[self.fixture.match('preview-changed', '交通', '447')])
export_custom_expense(changed, output, source, self.fixture.mapping,
{}, signatures, payee, {})
result = prepare_preview(output)
original, preview = Template(source), Template(output)
self.assertEqual(result['previewLayout']['version'], 4)
self.assertEqual(original.parts['xl/styles.xml'], preview.parts['xl/styles.xml'])
_, _, before = original.sheet('个人报销单')
_, _, after = preview.sheet('个人报销单')
for name in ['cols', 'mergeCells', 'sheetFormatPr', 'pageMargins', 'pageSetup']:
self.assertEqual(encoded(before.find(tag(name))), encoded(after.find(tag(name))), name)
for reference, label in zip(['A30', 'D30', 'A31', 'D31', 'A32'], signatures):
old = before.find('.//' + tag('c') + f'[@r="{reference}"]')
new = after.find('.//' + tag('c') + f'[@r="{reference}"]')
self.assertEqual(encoded(old), encoded(new), reference)
self.assertEqual(''.join(node.text or '' for node in new.iter(tag('t'))), label + '')
old_cells = {cell.get('r'): cell.get('s') for cell in before.iter(tag('c'))}
new_cells = {cell.get('r'): cell.get('s') for cell in after.iter(tag('c'))}
self.assertEqual(old_cells, new_cells)
for row in [30, 31, 32]:
old = before.find(tag('sheetData') + '/' + tag('row') + f'[@r="{row}"]')
new = after.find(tag('sheetData') + '/' + tag('row') + f'[@r="{row}"]')
self.assertEqual(old.attrib, new.attrib)
folder = os.environ.get('RECEIPT_PREVIEW_FIXTURE_DIR')
if folder:
folder = Path(folder)
folder.mkdir(parents=True, exist_ok=True)
(folder / 'template.xlsx').write_bytes(source.read_bytes())
(folder / 'preview.xlsx').write_bytes(output.read_bytes())
def test_builtin_uppercase_amount_formula(self):
for amount, expected in [('3491.86', '叁仟肆佰玖拾壹元捌角陆分'), ('10001.01', '壹万零壹元零壹分'),
('100000001', '壹亿零壹元整'), ('100010000', '壹亿零壹万元整'),
@@ -105,8 +143,9 @@ class ExpensePreviewTests(unittest.TestCase):
process = subprocess.run(command, input=json.dumps(request) + '\n', text=True, capture_output=True, timeout=60)
self.assertEqual(process.returncode, 0, process.stderr + process.stdout)
result = next(json.loads(line)['result'] for line in process.stdout.splitlines() if json.loads(line).get('event') == 'result')
self.assertEqual(result['expensePreview'], 'xlsx-layout-v2')
self.assertEqual(result['expensePreview'], 'xlsx-layout-v4')
self.assertTrue(result['previewLayout']['sheets'])
self.assertNotIn('templatePreviewLayout', result)
self.assertEqual(result['expenseRowCount'], 2)
workbook = load_workbook(output, data_only=True)
self.assertEqual(workbook.active['F27'].value, 544.3)
@@ -114,6 +153,22 @@ class ExpensePreviewTests(unittest.TestCase):
self.assertIn(workbook.active['C23'].value, ('', None))
workbook.close()
def test_engine_process_template_preview_is_read_only_without_expense_selection(self):
from spreadsheet_layout import read_layout
alternate, _ = self.fixture.alternate()
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
for source in [self.fixture.template, alternate]:
before = source.read_bytes()
request = dict(operation='preview-expense-template', templatePath=str(source))
process = subprocess.run(command, input=json.dumps(request) + '\n', text=True,
capture_output=True, timeout=60)
self.assertEqual(process.returncode, 0, process.stderr + process.stdout)
result = next(json.loads(line)['result'] for line in process.stdout.splitlines()
if json.loads(line).get('event') == 'result')
self.assertEqual(result, read_layout(source))
self.assertEqual(source.read_bytes(), before)
if __name__ == '__main__':
unittest.main()
+128 -79
View File
@@ -1,16 +1,15 @@
import json
import os
from pathlib import Path
import sys
import subprocess
import sys
import unittest
from lxml import etree as ET
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_expense_template as fixtures
from expense_template import Template, encoded, export_custom_expense, inspect_template, set_cell, tag
from expense_preview import prepare_preview
from expense_template import Template, encoded, tag
from spreadsheet_layout import formatted, read_layout
@@ -20,93 +19,143 @@ class SpreadsheetLayoutTests(unittest.TestCase):
self.fixture.setUp()
self.addCleanup(self.fixture.tearDown)
def test_fresh_import_does_not_read_system_mime_database(self):
code = """
import sys
def audit(event, args):
if event == 'open' and str(args[0]).endswith('mime.types'):
raise PermissionError('System MIME database is unavailable in the app sandbox')
sys.addaudithook(audit)
import spreadsheet_layout
"""
result = subprocess.run([sys.executable, '-c', code],
cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_actual_workbook_geometry_styles_and_values(self):
output, _ = self.fixture.export(payee=dict(recipient='测试收款人', bankName='测试银行测试支行',
accountNumber='001234567890'))
def test_wps_signature_and_accounting_regressions(self):
source = Path(os.environ.get('RECEIPT_PREVIEW_SOURCE', self.fixture.template))
original = source.read_bytes()
mapping = inspect_template(source)['sheets'][0]['mapping']
output = self.fixture.root / 'preview.xlsx'
export_custom_expense(dict(matches=[self.fixture.match('changed', '交通', '447')]),
output, source, mapping, {},
['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人'],
dict(recipient='测试收款人', bankName='测试银行', accountNumber='001234567890'), {})
result = prepare_preview(output)
before = output.read_bytes()
layout = read_layout(output)
self.assertEqual(before, output.read_bytes())
self.assertEqual(output.read_bytes(), before)
self.assertEqual(source.read_bytes(), original)
self.assertEqual(layout, result['previewLayout'])
sheet = layout['sheets'][0]
cells = {cell['reference']: cell for cell in sheet['cells']}
self.assertEqual(cells['C23']['text'], '测试收款人')
self.assertEqual(cells['C25']['text'], '001234567890')
self.assertEqual(cells['F27']['text'].strip(), '544.30')
self.assertEqual(cells['D29']['text'], '伍佰肆拾肆元叁角整')
self.assertNotIn('D23', cells)
self.assertAlmostEqual(cells['C23']['x'] + cells['C23']['width'], sheet['width'])
self.assertNotIn('templatePreviewLayout', result)
cells = {c['reference']: c for c in layout['sheets'][0]['cells']}
for ref, label in [('A30', '部门长'), ('D30', '剧组出纳'), ('A31', '制片主任'), ('D31', '剧组会计')]:
self.assertEqual(cells[ref]['text'], label + '')
self.assertGreater(cells[ref]['overflowRight'], cells[ref]['x'] + cells[ref]['width'])
self.assertEqual(cells['A30']['overflowRight'], cells['D30']['x'])
self.assertEqual(cells['H30']['style']['rotation'], 255)
self.assertGreater(cells['A30']['overflowRight'], cells['A30']['x'] + cells['A30']['width'])
self.assertLessEqual(cells['A30']['overflowRight'], cells['D30']['x'])
_, _, document = Template(output).sheet(sheet['name'])
row = document.find(tag('sheetData') + '/' + tag('row') + '[@r="30"]')
self.assertAlmostEqual(cells['A30']['height'], float(row.get('ht')) * 4 / 3)
self.assertAlmostEqual(cells['H30']['height'], sum(cells[ref]['height'] for ref in ['A30', 'A31', 'A32']))
self.assertEqual(cells['F27']['style']['horizontal'], 'center')
self.assertEqual(cells['F27']['style']['font'], 'SimSun-ExtB')
self.assertEqual(cells['C23']['style']['borders']['right']['style'], 'medium')
self.assertLess(sheet['width'], 1200)
self.assertGreater(sheet['height'], 1200)
self.assertEqual(cells['H30']['style']['vertical'], 'bottom')
self.assertEqual(cells['H30']['style']['borders']['right']['style'], 'medium')
self.assertEqual(cells['H30']['style']['borders']['bottom']['style'], 'medium')
for ref in ['F27', 'H29']:
self.assertTrue(cells[ref]['style']['accounting'])
self.assertEqual(cells[ref]['text'].strip(), '447.00')
self.assertTrue(cells[ref]['style']['bold'])
self.assertNotIn('left', cells['D31']['style']['borders'])
self.assertEqual(cells['C25']['text'], '001234567890')
if os.environ.get('RECEIPT_PREVIEW_FIXTURE_DIR'):
folder = Path(os.environ['RECEIPT_PREVIEW_FIXTURE_DIR'])
folder.mkdir(parents=True, exist_ok=True)
(folder / 'layout.json').write_text(json.dumps(layout, ensure_ascii=False), encoding='utf-8')
(folder / 'preview.xlsx').write_bytes(before)
def test_all_visible_sheets_and_pagination_remain_available(self):
template, mapping = self.fixture.alternate()
state = dict(matches=self.fixture.state['matches'] + [self.fixture.match('third', '住宿', '50')])
output, _ = self.fixture.export(mapping=mapping, template=template, state=state)
layout = prepare_preview(output)['previewLayout']
self.assertEqual([sheet['name'] for sheet in layout['sheets']], ['组 B 报销', '保留说明', '组 B 报销-续2'])
notes = layout['sheets'][1]
self.assertEqual(notes['cells'][0]['text'], '保持这个工作表不变')
self.assertTrue(all(sheet['width'] > 0 and sheet['height'] > 0 for sheet in layout['sheets']))
def test_overflow_stops_at_text_and_merged_cells(self):
output, _ = self.fixture.export()
template = Template(output)
_, path, document = template.sheet('个人报销单')
set_cell(document, 'B30', '阻止覆盖')
template.parts[path] = encoded(document)
self.fixture.rewrite(output, template.parts)
cells = {c['reference']: c for c in read_layout(output)['sheets'][0]['cells']}
self.assertEqual(cells['A30']['overflowRight'], cells['A30']['x'] + cells['A30']['width'])
self.assertEqual(cells['D30']['overflowRight'], cells['H30']['x'])
self.assertEqual(cells['A32']['overflowRight'], cells['A32']['x'] + cells['A32']['width'])
def test_hidden_rows_columns_and_cells_outside_print_area(self):
def test_visible_sheets_and_pagination(self):
source, mapping = self.fixture.alternate()
state = dict(matches=self.fixture.state['matches'] + [self.fixture.match('extra', '住宿', '50')])
output, _ = self.fixture.export(mapping=mapping, template=source, state=state)
layout = prepare_preview(output)['previewLayout']
self.assertEqual([s['name'] for s in layout['sheets']], ['组 B 报销', '保留说明', '组 B 报销-续2'])
def test_unrelated_template_style_and_geometry(self):
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
workbook = Workbook()
sheet = workbook.active
sheet.title = '横向费用单'
sheet.column_dimensions['A'].width = 4
sheet.column_dimensions['B'].width = 24
sheet.column_dimensions['C'].width = 12
sheet.column_dimensions['D'].width = 20
sheet.column_dimensions['E'].width = 16
sheet.column_dimensions['F'].width = 10
sheet.row_dimensions[2].height = 42
sheet.row_dimensions[6].height = 34
sheet.merge_cells('B2:F2')
sheet['B2'] = '另一种费用模板'
sheet['B2'].font = Font(name='Arial', size=18, bold=True, color='145A32')
sheet['B2'].fill = PatternFill('solid', fgColor='D5F5E3')
sheet['B2'].alignment = Alignment(horizontal='center', vertical='center')
sheet['B2'].border = Border(bottom=Side(style='double', color='145A32'))
for ref, text in [('B5', '说明'), ('D5', '金额'), ('E5', '比例'), ('F5', '编号')]:
sheet[ref] = text
sheet.merge_cells('B6:C6')
sheet['B6'] = '这里是长一些的费用说明,需要按模板换行'
sheet['B6'].alignment = Alignment(wrap_text=True, vertical='top')
sheet['D6'] = 1234.5
sheet['D6'].number_format = '#,##0.00'
sheet['D6'].alignment = Alignment(horizontal='right')
sheet['E6'] = .125
sheet['E6'].number_format = '0.0%'
sheet['F6'] = '000012'
sheet.print_area = 'A1:F8'
workbook.create_sheet('说明页')['A1'] = '附加工作表'
output = self.fixture.root / 'different-style.xlsx'
workbook.save(output)
before = output.read_bytes()
layout = read_layout(output)
self.assertEqual(output.read_bytes(), before)
self.assertEqual([s['name'] for s in layout['sheets']], ['横向费用单', '说明页'])
cells = {c['reference']: c for c in layout['sheets'][0]['cells']}
self.assertNotIn('C2', cells)
self.assertNotIn('C6', cells)
self.assertEqual(cells['B2']['height'], 56)
self.assertEqual(cells['B2']['style']['font'], 'Arial')
self.assertEqual(cells['B2']['style']['fill'], 'D5F5E3')
self.assertEqual(cells['B2']['style']['borders']['bottom']['style'], 'double')
self.assertTrue(cells['B6']['style']['wrap'])
self.assertEqual(cells['D6']['text'], '1,234.50')
self.assertEqual(cells['D6']['style']['horizontal'], 'right')
self.assertEqual(cells['E6']['text'], '12.5%')
self.assertEqual(cells['F6']['text'], '000012')
if os.environ.get('RECEIPT_PREVIEW_FIXTURE_DIR'):
folder = Path(os.environ['RECEIPT_PREVIEW_FIXTURE_DIR'])
folder.mkdir(parents=True, exist_ok=True)
(folder / 'alternate-layout.json').write_text(json.dumps(layout, ensure_ascii=False), encoding='utf-8')
def test_hidden_dimensions_and_outside_print_area(self):
output, _ = self.fixture.export()
template = Template(output)
_, path, document = template.sheet('个人报销单')
document.find(tag('cols'))[0].set('hidden', '1')
row = document.find(tag('sheetData'))[0]
row.set('hidden', '1')
from expense_template import set_cell
set_cell(document, 'J36', '不能遗漏打印区域以外的内容')
document.find(tag('sheetData'))[0].set('hidden', '1')
set_cell(document, 'J36', '打印范围外')
template.parts[path] = encoded(document)
self.fixture.rewrite(output, template.parts)
sheet = read_layout(output)['sheets'][0]
cells = {cell['reference']: cell for cell in sheet['cells']}
cells = {c['reference']: c for c in read_layout(output)['sheets'][0]['cells']}
self.assertNotIn('A9', cells)
self.assertNotIn('A1', cells)
self.assertEqual(cells['B9']['x'], 0)
self.assertIn('J36', cells)
def test_oversized_range_fails_instead_of_truncating(self):
def test_large_range_rejected(self):
output, _ = self.fixture.export()
template = Template(output)
_, path, document = template.sheet('个人报销单')
from expense_template import set_cell
set_cell(document, 'IV2000', 'too large')
set_cell(document, 'IV2000', 'range')
template.parts[path] = encoded(document)
self.fixture.rewrite(output, template.parts)
with self.assertRaisesRegex(ValueError, '范围过大'):
read_layout(output)
def test_drawings_and_conditional_formatting_have_explicit_warning(self):
def test_advanced_features_warn(self):
output, _ = self.fixture.export()
template = Template(output)
_, path, document = template.sheet('个人报销单')
@@ -115,29 +164,29 @@ import spreadsheet_layout
self.fixture.rewrite(output, template.parts)
self.assertTrue(read_layout(output)['warnings'])
def test_number_formats_and_text_identity(self):
def test_numeric_and_date_formatting(self):
warnings = set()
for value, kind, code, expected in [
('2122.60', 'n', '#,##0.00', '2,122.60'),
('662.8', 'n', '0.00_ ', '662.80'),
('0', 'n', '#,##0.00;(#,##0.00);"-"', '-'),
('-25.3', 'n', '#,##0.00;(#,##0.00)', '(25.30)'),
('0.125', 'n', '0.0%', '12.5%'),
('8', 'n', '0000', '0008'),
('80', 'n', '"¥"#,##0.00', '¥80.00'),
('001234567890123456789', 'inlineStr', 'General', '001234567890123456789'),
('=NotAFormula', 'inlineStr', 'General', '=NotAFormula'),
('1', 'b', 'General', 'TRUE'),
('12.50', 'n', 'General', '12.5'),
('12.50', 'n', '0.##', '12.5'),
('12', 'n', '0.0#', '12.0'),
('1', 'n', 'mm-dd-yy', '01-01-00'),
]:
with self.subTest(value=value, code=code):
self.assertEqual(formatted(value, kind, code, False, warnings), expected)
for value, code, expected in [('447', '_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)', '447.00'),
('2122.6', '#,##0.00', '2,122.60'), ('-25.3', '0.00;(0.00)', '(25.30)'),
('12.50', '0.##', '12.5'), ('12', '0.0#', '12.0'),
('8', '0000', '0008'), ('0.125', '0.0%', '12.5%')]:
self.assertEqual(formatted(value, 'n', code, False, warnings).strip(), expected)
self.assertEqual(formatted('1', 'n', 'yyyy/m/d', True, warnings), '1904/1/2')
self.assertFalse(warnings)
def test_sandbox_import(self):
script = """
import sys
def audit(event, args):
if event == 'open' and str(args[0]).endswith('mime.types'):
raise PermissionError('System MIME database is unavailable')
sys.addaudithook(audit)
import spreadsheet_layout
"""
result = subprocess.run([sys.executable, '-c', script], cwd=Path(__file__).resolve().parents[1],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == '__main__':
unittest.main()