diff --git a/README.md b/README.md index f504a6b..14caa36 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ ### 导出前预览 -在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。应用先按当前模板生成实际 Excel,在应用内使用 macOS Quick Look 展示完整表格(含各工作表),而不是另外绘制一份费用摘要。可返回修改,确认无误后点击“确认并保存”选择位置;保存的字节与预览文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 +在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。应用先按当前模板生成实际 Excel,再从该文件读取列宽、行高、合并区域、文字和边框样式进行原生表格预览。默认适合宽度且不自动放大,可切换整页、100%、手动缩放及工作表,不单独挤压列宽。可返回修改,确认无误后点击“确认并保存”选择位置;保存的字节与预览文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 + +预览优先使用模板字体,未安装的字体使用本机同类字体替代。图片、图表、条件格式及部分特殊数字格式会提示在 Excel / WPS 中核对,不保证这些高级内容逐像素一致;模板导入窗口仍使用系统 Quick Look。 预览会刷新受支持公式的缓存(求和、同表引用、基本四则运算、条件判断、今日日期及内置人民币大写公式),保留公式本身。复杂自定义公式不猜测结果:移除旧缓存,明确列出尚未计算的单元格,由 Excel / WPS 打开后重算。预览属于屏幕表格展示,不是分页打印校样;打印设置保留在 Excel 中。 diff --git a/native-engine/expense_preview.py b/native-engine/expense_preview.py index 089f0fc..ad63719 100644 --- a/native-engine/expense_preview.py +++ b/native-engine/expense_preview.py @@ -188,6 +188,7 @@ class PreviewCalculator: def prepare_preview(path): from lxml import etree as ET + from spreadsheet_layout import read_layout template = Template(path) properties = template.workbook.find(tag('workbookPr')) @@ -217,4 +218,5 @@ 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-quicklook-v1', previewUncalculatedCells=uncalculated) + return dict(expensePreview='xlsx-layout-v2', previewUncalculatedCells=uncalculated, + previewLayout=read_layout(path)) diff --git a/native-engine/spreadsheet_layout.py b/native-engine/spreadsheet_layout.py new file mode 100644 index 0000000..be908f5 --- /dev/null +++ b/native-engine/spreadsheet_layout.py @@ -0,0 +1,272 @@ +"""Read-only display geometry from the exact XLSX that will be saved.""" +import datetime +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. +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 + + +def flag(node, name, default=False): + return node.get(name, '1' if default else '0') in ('1', 'true') + + +def child_value(node, name, fallback): + child = node.find(tag(name)) + return child.get('val', fallback) if child is not None else fallback + + +def theme_colors(template): + if 'xl/theme/theme1.xml' not in template.parts: + return ['FFFFFF', '000000', 'EEECE1', '1F497D', '4F81BD', 'C0504D', + '9BBB59', '8064A2', '4BACC6', 'F79646', '0000FF', '800080'] + 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']] + + +def color(node, palette, fallback='000000'): + if node is None: + return fallback + 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:] + if not re.fullmatch('[0-9a-fA-F]{6}', value): + return fallback + 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))) + + +def literal(value): + return re.sub(r'"([^"]*)"|\\(.)|_.|\*.', lambda m: m[1] or m[2] or '', value) + + +def formatted(value, kind, code, date_1904, warnings): + if 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() + 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 = re.sub(r'\[\$([^\]-]*)[^\]]*\]', lambda m: m[1], section) + if re.search(r'\[(?:[<>=]|\d)', section): + 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('部分科学计数或分数格式使用原始数值显示') + 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 + 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] + 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():]) + + +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')) + formats = dict(BUILTIN_FORMATS) + for item in root.findall(tag('numFmts') + '/' + tag('numFmt')): + formats[int(item.get('numFmtId'))] = item.get('formatCode') + styles = [] + for entry in root.find(tag('cellXfs')): + font = fonts[int(entry.get('fontId', 0))] + fill = fills[int(entry.get('fillId', 0))].find(tag('patternFill')) + border = borders[int(entry.get('borderId', 0))] + alignment = entry.find(tag('alignment')) + if alignment is None: + alignment = {} + sides = {} + for edge in ('left', 'right', 'top', 'bottom'): + side = border.find(tag(edge)) + if side is not None and side.get('style'): + sides[edge] = dict(style=side.get('style'), color=color(side.find(tag('color')), palette)) + styles.append(dict( + font=child_value(font, 'name', 'Calibri'), fontSize=float(child_value(font, 'sz', '11')), + bold=font.find(tag('b')) is not None and flag(font.find(tag('b')), 'val', True), + italic=font.find(tag('i')) is not None and flag(font.find(tag('i')), 'val', True), + underline=font.find(tag('u')) is not None and child_value(font, 'u', 'single') != 'none', + strike=font.find(tag('strike')) is not None and flag(font.find(tag('strike')), 'val', True), + color=color(font.find(tag('color')), palette), + fill=color(fill.find(tag('fgColor')), palette, 'FFFFFF') if fill is not None and fill.get('patternType') == 'solid' else 'FFFFFF', + 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') + )) + return styles + + +def read_layout(path): + template = Template(path) + palette = theme_colors(template) + styles = read_styles(template, palette) + properties = template.workbook.find(tag('workbookPr')) + date_1904 = properties is not None and flag(properties, 'date1904') + warnings = set() + sheets, total_cells = [], 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 = [] + 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] + 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: + 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 + 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 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] + xs, ys = [0], [0] + for width in widths: + xs.append(xs[-1] + width) + for height in heights: + ys.append(ys[-1] + height) + 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} + occupied.update(covered) + occupied.update(coordinate(ref) for ref in spans) + 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['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 candidate: + appearance['borders'][edge] = candidate + kind = cell.get('t', 'n') + value = cell.findtext(tag('v'), '') + 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'))) + text = formatted(value, kind, appearance['numberFormat'], date_1904, warnings) + 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])) + 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)) diff --git a/native-engine/tests/test_expense_preview.py b/native-engine/tests/test_expense_preview.py index a96fad2..d6b9615 100644 --- a/native-engine/tests/test_expense_preview.py +++ b/native-engine/tests/test_expense_preview.py @@ -36,7 +36,7 @@ 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-quicklook-v1') + self.assertEqual(result['expensePreview'], 'xlsx-layout-v2') values.close() formulas.close() @@ -105,7 +105,8 @@ 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-quicklook-v1') + self.assertEqual(result['expensePreview'], 'xlsx-layout-v2') + self.assertTrue(result['previewLayout']['sheets']) self.assertEqual(result['expenseRowCount'], 2) workbook = load_workbook(output, data_only=True) self.assertEqual(workbook.active['F27'].value, 544.3) diff --git a/native-engine/tests/test_spreadsheet_layout.py b/native-engine/tests/test_spreadsheet_layout.py new file mode 100644 index 0000000..f82a569 --- /dev/null +++ b/native-engine/tests/test_spreadsheet_layout.py @@ -0,0 +1,143 @@ +import json +import os +from pathlib import Path +import sys +import subprocess +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_preview import prepare_preview +from expense_template import Template, encoded, tag +from spreadsheet_layout import formatted, read_layout + + +class SpreadsheetLayoutTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.ExpenseTemplateTests() + 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')) + result = prepare_preview(output) + before = output.read_bytes() + layout = read_layout(output) + self.assertEqual(before, output.read_bytes()) + 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.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) + 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_hidden_rows_columns_and_cells_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', '不能遗漏打印区域以外的内容') + 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']} + 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): + output, _ = self.fixture.export() + template = Template(output) + _, path, document = template.sheet('个人报销单') + from expense_template import set_cell + set_cell(document, 'IV2000', 'too large') + 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): + output, _ = self.fixture.export() + template = Template(output) + _, path, document = template.sheet('个人报销单') + ET.SubElement(document, tag('conditionalFormatting'), sqref='H9') + template.parts[path] = encoded(document) + self.fixture.rewrite(output, template.parts) + self.assertTrue(read_layout(output)['warnings']) + + def test_number_formats_and_text_identity(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) + self.assertEqual(formatted('1', 'n', 'yyyy/m/d', True, warnings), '1904/1/2') + self.assertFalse(warnings) + + +if __name__ == '__main__': + unittest.main() diff --git a/reimburse/ExpensePreview.swift b/reimburse/ExpensePreview.swift index 5976c21..e9c9048 100644 --- a/reimburse/ExpensePreview.swift +++ b/reimburse/ExpensePreview.swift @@ -25,6 +25,7 @@ struct PreparedExpensePreview { let fileDigest: String let templateName: String let uncalculatedCells: [String] + var layout: SpreadsheetLayout? = nil static func digest(_ request: [String: Any]) throws -> String { var inputs = request diff --git a/reimburse/ExpensePreviewSheet.swift b/reimburse/ExpensePreviewSheet.swift index 22a71da..811f8a7 100644 --- a/reimburse/ExpensePreviewSheet.swift +++ b/reimburse/ExpensePreviewSheet.swift @@ -46,8 +46,8 @@ struct ExpensePreviewSheet: View { Text("正在填入所选费用、收款资料和签字岗位…") Button("取消生成", action: store.cancel) }.frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let preview = store.expensePreview { - SpreadsheetPreview(url: preview.file).id(preview.file) + } else if let preview = store.expensePreview, let layout = preview.layout { + ExpenseSpreadsheetPreview(layout: layout).id(preview.file) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView("暂时无法生成预览", systemImage: "doc.badge.ellipsis", @@ -63,9 +63,14 @@ struct ExpensePreviewSheet: View { .font(.caption).foregroundStyle(.orange) .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 20).padding(.bottom, 10) } + if let warnings = store.expensePreview?.layout?.warnings, !warnings.isEmpty { + Text(warnings.joined(separator: ";")) + .font(.caption).foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 20).padding(.bottom, 10) + } Divider() HStack { - Text("预览的是待保存的 Excel;确认后保存同一份文件。") + Text("确认后保存此报销单") .font(.caption).foregroundStyle(.secondary) Spacer() Button("返回修改") { dismiss() }.keyboardShortcut(.cancelAction) @@ -77,7 +82,7 @@ struct ExpensePreviewSheet: View { .disabled(store.expensePreview == nil || store.expensePreviewError != nil) }.padding(20).disabled(store.busy) } - .frame(width: 980, height: 720) + .frame(width: 1080, height: 780) .interactiveDismissDisabled(store.busy) .onDisappear { store.discardExpensePreview() } } diff --git a/reimburse/NativeDiagnostics.swift b/reimburse/NativeDiagnostics.swift index 97e38a3..2858484 100644 --- a/reimburse/NativeDiagnostics.swift +++ b/reimburse/NativeDiagnostics.swift @@ -131,14 +131,18 @@ enum NativeDiagnostics { try ExpenseSummary.validateExportResult(customResult, matches: groupedState.matches) try ExpenseTemplateStorage.validateExport(customResult, template: savedTemplate) let previewMetadata = try JSONSerialization.jsonObject(with: customResult) as? [String: Any] - guard previewMetadata?["expensePreview"] as? String == "xlsx-quicklook-v1" else { + guard previewMetadata?["expensePreview"] as? String == "xlsx-layout-v2", + let layoutObject = previewMetadata?["previewLayout"] else { throw EngineFailure.message("应用内报销单预览未使用新版引擎") } + let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: + JSONSerialization.data(withJSONObject: layoutObject)).validated() let generatedData = try Data(contentsOf: customDestination) let prepared = PreparedExpensePreview( file: customDestination, requestDigest: try PreparedExpensePreview.digest(customRequest), fileDigest: ExpenseTemplateStorage.fingerprint(generatedData), templateName: savedTemplate.name, - uncalculatedCells: previewMetadata?["previewUncalculatedCells"] as? [String] ?? [] + uncalculatedCells: previewMetadata?["previewUncalculatedCells"] as? [String] ?? [], + layout: layout ) let exactPreviewData = try prepared.verifiedData(request: customRequest) guard exactPreviewData == generatedData else { throw EngineFailure.message("预览和导出文件不一致") } diff --git a/reimburse/SpreadsheetLayout.swift b/reimburse/SpreadsheetLayout.swift new file mode 100644 index 0000000..3467dd2 --- /dev/null +++ b/reimburse/SpreadsheetLayout.swift @@ -0,0 +1,312 @@ +import AppKit +import SwiftUI + +struct SpreadsheetLayout: Decodable { + let version: Int + let sheets: [SpreadsheetSheet] + let warnings: [String] + + func validated() throws -> SpreadsheetLayout { + guard version == 1, !sheets.isEmpty, sheets.allSatisfy({ + $0.width.isFinite && $0.height.isFinite && $0.width > 0 && $0.height > 0 && + $0.cells.allSatisfy { cell in + [cell.x, cell.y, cell.width, cell.height, cell.style.fontSize, + cell.overflowLeft, cell.overflowRight].allSatisfy(\.isFinite) && + cell.x >= 0 && cell.y >= 0 && cell.width > 0 && cell.height > 0 && + cell.style.fontSize > 0 && cell.overflowLeft >= 0 && + cell.overflowLeft <= cell.x && cell.overflowRight >= cell.x + cell.width + } + }) else { + throw ExpenseTemplateFailure.message("报销单排版数据不完整,请重新生成预览。") + } + return self + } +} + +struct SpreadsheetSheet: Decodable { + let name: String + let width: Double + let height: Double + let cells: [SpreadsheetCell] +} + +struct SpreadsheetCell: Decodable { + let reference: String + let x: Double + let y: Double + let width: Double + let height: Double + let text: String + let style: SpreadsheetStyle + let overflowLeft: Double + let overflowRight: Double + var rect: NSRect { NSRect(x: x, y: y, width: width, height: height) } + var textClip: NSRect { + NSRect(x: overflowLeft, y: y, width: overflowRight - overflowLeft, height: height) + } +} + +struct SpreadsheetBorder: Decodable { + let style: String + let color: String + + var width: CGFloat { + switch style { + case "hair": 0.5 + case "medium", "mediumDashed", "mediumDashDot", "mediumDashDotDot": 2 + case "thick", "double": 3 + default: 1 + } + } +} + +struct SpreadsheetStyle: Decodable { + let font: String + let fontSize: Double + let bold: Bool + let italic: Bool + let underline: Bool + let strike: Bool + let color: String + let fill: String + let horizontal: String + let vertical: String + let wrap: Bool + let shrink: Bool + let rotation: Int + let indent: Double + let borders: [String: SpreadsheetBorder] + + @MainActor func resolvedFont(size: CGFloat? = nil) -> NSFont { + let pointSize: CGFloat = size ?? CGFloat(fontSize * 4 / 3) + let lower = font.lowercased() + let fallback: String + if lower.contains("simsun") || font.contains("宋") { + fallback = "Songti SC" + } else if lower.contains("kai") || font.contains("楷") { + fallback = "Kaiti SC" + } else { + fallback = "PingFang SC" + } + var result = NSFont(name: font, size: pointSize) ?? NSFont(name: fallback, size: pointSize) + ?? NSFont.systemFont(ofSize: pointSize) + if bold { result = NSFontManager.shared.convert(result, toHaveTrait: .boldFontMask) } + if italic { result = NSFontManager.shared.convert(result, toHaveTrait: .italicFontMask) } + return result + } +} + +enum SpreadsheetZoom: String, CaseIterable { + case width = "适合宽度" + case page = "整页" + case actual = "100%" + + func scale(sheet: SpreadsheetSheet, viewport: CGSize) -> CGFloat { + let width = max(1, viewport.width - 48) / sheet.width + let height = max(1, viewport.height - 48) / sheet.height + switch self { + case .width: return max(0.05, min(1, width)) + case .page: return max(0.05, min(1, width, height)) + case .actual: return 1 + } + } +} + +struct ExpenseSpreadsheetPreview: View { + let layout: SpreadsheetLayout + @State private var sheetIndex = 0 + @State private var zoom: SpreadsheetZoom = .width + @State private var customScale: CGFloat? + + var body: some View { + GeometryReader { geometry in + let sheet = layout.sheets[min(sheetIndex, layout.sheets.count - 1)] + let viewport = CGSize(width: geometry.size.width, height: max(1, geometry.size.height - 48)) + let scale = customScale ?? zoom.scale(sheet: sheet, viewport: viewport) + VStack(spacing: 0) { + HStack(spacing: 12) { + Picker("工作表", selection: $sheetIndex) { + ForEach(layout.sheets.indices, id: \.self) { index in + Text(layout.sheets[index].name).tag(index) + } + }.frame(maxWidth: 260) + Spacer(minLength: 8) + Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: { + if let mode = $0 { zoom = mode; customScale = nil } + })) { + ForEach(SpreadsheetZoom.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(Optional(mode)) + } + }.pickerStyle(.segmented).frame(width: 220) + Button { + customScale = max(0.1, scale - 0.1) + } label: { Image(systemName: "minus.magnifyingglass") } + .help("缩小").disabled(scale <= 0.1) + Text(Double(scale).formatted(.percent.precision(.fractionLength(0)))) + .font(.callout.monospacedDigit()).frame(width: 48) + Button { + customScale = min(2.5, scale + 0.1) + } label: { Image(systemName: "plus.magnifyingglass") } + .help("放大").disabled(scale >= 2.5) + }.padding(.horizontal, 20).frame(height: 48) + .background(Color(nsColor: .windowBackgroundColor)) + Divider() + ScrollView([.horizontal, .vertical]) { + SpreadsheetCanvas(sheet: sheet, scale: scale) + .frame(width: sheet.width * scale, height: sheet.height * scale) + .background(.white) + .overlay(Rectangle().stroke(Color.black.opacity(0.12), lineWidth: 1)) + .padding(24) + .frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top) + } + .background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1))) + .id(sheetIndex) + } + .onChange(of: zoom) { _, _ in customScale = nil } + .onChange(of: sheetIndex) { _, _ in customScale = nil } + } + } +} + +struct SpreadsheetCanvas: NSViewRepresentable { + let sheet: SpreadsheetSheet + let scale: CGFloat + + func makeNSView(context: Context) -> SpreadsheetCanvasView { + SpreadsheetCanvasView(sheet: sheet, scale: scale) + } + + func updateNSView(_ view: SpreadsheetCanvasView, context: Context) { + view.sheet = sheet + view.scale = scale + view.needsDisplay = true + } +} + +@MainActor final class SpreadsheetCanvasView: NSView { + var sheet: SpreadsheetSheet + var scale: CGFloat + override var isFlipped: Bool { true } + override var isOpaque: Bool { true } + + init(sheet: SpreadsheetSheet, scale: CGFloat) { + self.sheet = sheet + self.scale = scale + super.init(frame: NSRect(x: 0, y: 0, width: sheet.width * scale, height: sheet.height * scale)) + setAccessibilityLabel(sheet.name) + setAccessibilityRole(.image) + } + + required init?(coder: NSCoder) { nil } + + override func draw(_ dirtyRect: NSRect) { + NSColor.white.setFill() + dirtyRect.fill() + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + defer { context.restoreGState() } + context.scaleBy(x: scale, y: scale) + let visible = NSRect(x: dirtyRect.minX / scale, y: dirtyRect.minY / scale, + width: dirtyRect.width / scale, height: dirtyRect.height / scale).insetBy(dx: -3, dy: -3) + let cells = sheet.cells.filter { $0.textClip.intersects(visible) } + // Fills, text and borders are separate passes so adjacent fills never + // erase shared cell borders. The entire sheet uses one uniform scale. + for cell in cells { + Self.color(cell.style.fill).setFill() + cell.rect.fill() + } + for cell in cells where !cell.text.isEmpty { drawText(cell, context: context) } + for cell in cells { drawBorders(cell, context: context) } + } + + static func color(_ hex: String) -> NSColor { + let value = UInt32(hex, radix: 16) ?? 0 + return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255, + green: CGFloat((value >> 8) & 255) / 255, + blue: CGFloat(value & 255) / 255, alpha: 1) + } + + private func drawText(_ cell: SpreadsheetCell, context: CGContext) { + let style = cell.style + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = style.horizontal == "center" || style.horizontal == "centerContinuous" ? .center : + style.horizontal == "right" ? .right : .left + paragraph.lineBreakMode = style.wrap || style.rotation == 255 ? .byWordWrapping : .byClipping + let text = style.rotation == 255 ? cell.text.filter { !$0.isNewline }.map(String.init).joined(separator: "\n") : cell.text + var font = style.resolvedFont() + var box = cell.rect.insetBy(dx: 3, dy: 1) + let indent = style.indent * font.pointSize * 0.9 + if paragraph.alignment == .left { box.origin.x += indent; box.size.width -= indent } + if paragraph.alignment == .right { box.size.width -= indent } + guard box.width > 0 && box.height > 0 else { return } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: Self.color(style.color), .paragraphStyle: paragraph + ] + if style.underline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } + if style.strike { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } + if style.shrink && !style.wrap && style.rotation == 0 { + let natural = (text as NSString).size(withAttributes: attributes).width + if natural > box.width { + font = style.resolvedFont(size: max(1, font.pointSize * box.width / natural)) + attributes[.font] = font + } + } + context.saveGState() + defer { context.restoreGState() } + context.clip(to: cell.textClip) + if cell.textClip.width > cell.width { + let natural = ceil((text as NSString).size(withAttributes: attributes).width) + if natural > box.width { + if paragraph.alignment == .right { box.origin.x -= natural - box.width } + else if paragraph.alignment == .center { box.origin.x -= (natural - box.width) / 2 } + box.size.width = natural + } + } + if (1...180).contains(style.rotation) { + let angle = style.rotation <= 90 ? style.rotation : 90 - style.rotation + context.translateBy(x: box.midX, y: box.midY) + context.rotate(by: -CGFloat(angle) * .pi / 180) + box = NSRect(x: -box.height / 2, y: -box.width / 2, width: box.height, height: box.width) + } + let attributed = NSAttributedString(string: text, attributes: attributes) + let options: NSString.DrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading] + let measured = attributed.boundingRect(with: NSSize(width: box.width, height: 100000), options: options) + let textHeight = min(box.height, ceil(measured.height)) + if style.vertical == "center" { box.origin.y += (box.height - textHeight) / 2 } + else if style.vertical != "top" { box.origin.y += box.height - textHeight } + box.size.height = textHeight + attributed.draw(with: box, options: options) + } + + private func drawBorders(_ cell: SpreadsheetCell, context: CGContext) { + let rect = cell.rect + for (edge, border) in cell.style.borders { + let start: CGPoint + let end: CGPoint + switch edge { + case "top": start = CGPoint(x: rect.minX, y: rect.minY); end = CGPoint(x: rect.maxX, y: rect.minY) + case "bottom": start = CGPoint(x: rect.minX, y: rect.maxY); end = CGPoint(x: rect.maxX, y: rect.maxY) + case "left": start = CGPoint(x: rect.minX, y: rect.minY); end = CGPoint(x: rect.minX, y: rect.maxY) + default: start = CGPoint(x: rect.maxX, y: rect.minY); end = CGPoint(x: rect.maxX, y: rect.maxY) + } + context.saveGState() + context.setStrokeColor(Self.color(border.color).cgColor) + context.setLineWidth(border.style == "double" ? 1 : border.width) + if border.style.lowercased().contains("dash") { context.setLineDash(phase: 0, lengths: [5, 3]) } + if border.style == "dotted" { context.setLineDash(phase: 0, lengths: [1, 2]) } + if border.style == "double" { + for offset in [-1.0, 1.0] { + let dx = edge == "left" || edge == "right" ? offset : 0 + let dy = dx == 0 ? offset : 0 + context.move(to: CGPoint(x: start.x + dx, y: start.y + dy)) + context.addLine(to: CGPoint(x: end.x + dx, y: end.y + dy)) + } + } else { + context.move(to: start) + context.addLine(to: end) + } + context.strokePath() + context.restoreGState() + } + } +} diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index aaff048..759843a 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -447,13 +447,17 @@ final class WorkspaceStore: ObservableObject { try ExpenseSummary.validateExportResult(result, matches: snapshot.matches) if let template = snapshot.template { try ExpenseTemplateStorage.validateExport(result, template: template) } let metadata = try JSONSerialization.jsonObject(with: result) as? [String: Any] - guard metadata?["expensePreview"] as? String == "xlsx-quicklook-v1" else { + guard metadata?["expensePreview"] as? String == "xlsx-layout-v2", + let layoutObject = metadata?["previewLayout"] else { throw ExpenseTemplateFailure.message("当前引擎不支持报销单预览,请重新构建应用。") } + let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: + JSONSerialization.data(withJSONObject: layoutObject)).validated() expensePreview = PreparedExpensePreview( file: file, requestDigest: digest, fileDigest: ExpenseTemplateStorage.fingerprint(try Data(contentsOf: file)), - templateName: name, uncalculatedCells: metadata?["previewUncalculatedCells"] as? [String] ?? [] + templateName: name, uncalculatedCells: metadata?["previewUncalculatedCells"] as? [String] ?? [], + layout: layout ) } catch { try? FileManager.default.removeItem(at: file) diff --git a/tests/SpreadsheetLayoutTests.swift b/tests/SpreadsheetLayoutTests.swift new file mode 100644 index 0000000..a38f5d3 --- /dev/null +++ b/tests/SpreadsheetLayoutTests.swift @@ -0,0 +1,54 @@ +import AppKit +import SwiftUI + +@main +struct SpreadsheetLayoutTests { + @MainActor static func main() throws { + let source = URL(fileURLWithPath: CommandLine.arguments[1]) + let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: Data(contentsOf: source)).validated() + let sheet = layout.sheets[0] + let narrow = SpreadsheetZoom.width.scale(sheet: sheet, viewport: CGSize(width: 800, height: 500)) + let wide = SpreadsheetZoom.width.scale(sheet: sheet, viewport: CGSize(width: 1800, height: 900)) + precondition(narrow * sheet.width <= 752) + precondition(wide == 1) + let page = SpreadsheetZoom.page.scale(sheet: sheet, viewport: CGSize(width: 800, height: 500)) + precondition(page * sheet.height <= 452) + precondition(SpreadsheetZoom.actual.scale(sheet: sheet, viewport: .zero) == 1) + precondition(sheet.cells.first { $0.reference == "H30" }?.style.rotation == 255) + precondition(sheet.cells.first { $0.reference == "C25" }?.text == "001234567890") + precondition(sheet.cells.first { $0.reference == "F27" }?.text.trimmingCharacters(in: .whitespaces) == "544.30") + let signature = sheet.cells.first { $0.reference == "A30" }! + precondition(signature.textClip.width > signature.width) + print("Workbook layout, zoom bounds, vertical text and exact values passed") + if CommandLine.arguments.count > 2 { + let folder = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + _ = NSApplication.shared + NSApplication.shared.appearance = NSAppearance(named: .aqua) + let canvas = SpreadsheetCanvasView(sheet: sheet, scale: 1) + try capture(canvas, to: folder.appendingPathComponent("sheet.png")) + for size in [CGSize(width: 1080, height: 620), CGSize(width: 820, height: 480)] { + let host = NSHostingView(rootView: ExpenseSpreadsheetPreview(layout: layout)) + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + host.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + try capture(host, to: folder.appendingPathComponent("preview-\(Int(size.width)).png")) + window.orderOut(nil) + } + print("Native preview snapshots written to \(folder.path)") + } + } + + @MainActor static func capture(_ view: NSView, to file: URL) throws { + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { + throw ExpenseTemplateFailure.message("Cannot create preview snapshot") + } + view.cacheDisplay(in: view.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + throw ExpenseTemplateFailure.message("Cannot encode preview snapshot") + } + try png.write(to: file) + } +}