diff --git a/README.md b/README.md index 14caa36..93c72d4 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ 在工具栏点击“报销模板”,或点击材料扫描页、个人报销单弹窗中的“选择模板 / 查看、更换”。也可以直接从访达将一个 `.xlsx` 文件拖到模板卡片或模板窗口上方的虚线区域;拖入时高亮,处理中不接受重复导入。不接受文件夹、多个文件或网页链接,失败时原模板保持不变。 -模板窗口左侧显示 Excel 原表,右侧显示识别到的工作表、明细范围和收款位置。核对后点击“确认位置,使用此模板”。不再默认展示整屏地址输入框;仅在识别不正确时打开“调整填写位置”。未确认的新模板不会覆盖正在使用的模板,可随时放弃更换。 +模板窗口左侧显示 Excel 原表,右侧显示识别到的工作表、明细范围和收款位置。“查看 / 更换”、新导入确认页与报销单预览共用同一套表格渲染,不再使用系统 Quick Look;切换填写工作表时同步显示对应工作表。模板预览为独立只读操作,不需要勾选费用,不计算或回写原模板公式,加载失败可重试。核对后点击“确认位置,使用此模板”。仅在识别不正确时打开“调整填写位置”。未确认的新模板不会覆盖正在使用的模板,可随时放弃更换。 模板只保存本机副本,重启后仍有效,不修改导入的原始文件。仅影响个人报销单,不影响 PPT、行程表及勾选范围;仍按所选费用类型合并金额、单据数,每类型一行。保留模板样式、合并区域、打印设置、其他工作表和未映射内容;明细超出确认范围时复制报销工作表分页。合计和其他公式在 Excel / WPS 打开后重新计算。 @@ -56,9 +56,9 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ ### 导出前预览 -在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。应用先按当前模板生成实际 Excel,再从该文件读取列宽、行高、合并区域、文字和边框样式进行原生表格预览。默认适合宽度且不自动放大,可切换整页、100%、手动缩放及工作表,不单独挤压列宽。可返回修改,确认无误后点击“确认并保存”选择位置;保存的字节与预览文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 +在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 -预览优先使用模板字体,未安装的字体使用本机同类字体替代。图片、图表、条件格式及部分特殊数字格式会提示在 Excel / WPS 中核对,不保证这些高级内容逐像素一致;模板导入窗口仍使用系统 Quick Look。 +跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 WPS 逐像素相同。预览限制不改变导出的模板样式。 预览会刷新受支持公式的缓存(求和、同表引用、基本四则运算、条件判断、今日日期及内置人民币大写公式),保留公式本身。复杂自定义公式不猜测结果:移除旧缓存,明确列出尚未计算的单元格,由 Excel / WPS 打开后重算。预览属于屏幕表格展示,不是分页打印校样;打印设置保留在 Excel 中。 diff --git a/native-engine/engine.py b/native-engine/engine.py index bf996ce..3393837 100644 --- a/native-engine/engine.py +++ b/native-engine/engine.py @@ -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'] diff --git a/native-engine/expense_preview.py b/native-engine/expense_preview.py index ad63719..ed53135 100644 --- a/native-engine/expense_preview.py +++ b/native-engine/expense_preview.py @@ -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 diff --git a/native-engine/spreadsheet_layout.py b/native-engine/spreadsheet_layout.py index be908f5..22c9578 100644 --- a/native-engine/spreadsheet_layout.py +++ b/native-engine/spreadsheet_layout.py @@ -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)) diff --git a/native-engine/tests/test_expense_preview.py b/native-engine/tests/test_expense_preview.py index d6b9615..d751fa5 100644 --- a/native-engine/tests/test_expense_preview.py +++ b/native-engine/tests/test_expense_preview.py @@ -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() diff --git a/native-engine/tests/test_spreadsheet_layout.py b/native-engine/tests/test_spreadsheet_layout.py index f82a569..47b4190 100644 --- a/native-engine/tests/test_spreadsheet_layout.py +++ b/native-engine/tests/test_spreadsheet_layout.py @@ -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() diff --git a/reimburse/ExpensePreviewSheet.swift b/reimburse/ExpensePreviewSheet.swift index 811f8a7..ec57a31 100644 --- a/reimburse/ExpensePreviewSheet.swift +++ b/reimburse/ExpensePreviewSheet.swift @@ -1,27 +1,4 @@ import SwiftUI -import Quartz - -struct SpreadsheetPreview: NSViewRepresentable { - let url: URL - - func makeNSView(context: Context) -> QLPreviewView { - let view = QLPreviewView(frame: .zero, style: .normal)! - view.shouldCloseWithWindow = false - view.autostarts = true - view.previewItem = url as NSURL - return view - } - - func updateNSView(_ view: QLPreviewView, context: Context) { - if view.previewItem?.previewItemURL != url { - view.previewItem = url as NSURL - } - } - - static func dismantleNSView(_ view: QLPreviewView, coordinator: ()) { - view.close() - } -} struct ExpensePreviewSheet: View { @EnvironmentObject var store: WorkspaceStore @@ -47,7 +24,7 @@ struct ExpensePreviewSheet: View { Button("取消生成", action: store.cancel) }.frame(maxWidth: .infinity, maxHeight: .infinity) } else if let preview = store.expensePreview, let layout = preview.layout { - ExpenseSpreadsheetPreview(layout: layout).id(preview.file) + ExpenseDocumentPreview(layout: layout).id(preview.file) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView("暂时无法生成预览", systemImage: "doc.badge.ellipsis", @@ -63,11 +40,6 @@ 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("确认后保存此报销单") @@ -82,7 +54,8 @@ struct ExpensePreviewSheet: View { .disabled(store.expensePreview == nil || store.expensePreviewError != nil) }.padding(20).disabled(store.busy) } - .frame(width: 1080, height: 780) + .frame(width: min(1200, (NSScreen.main?.visibleFrame.width ?? 1280) - 80), + height: min(820, (NSScreen.main?.visibleFrame.height ?? 900) - 80)) .interactiveDismissDisabled(store.busy) .onDisappear { store.discardExpensePreview() } } diff --git a/reimburse/ExpenseTemplateSheet.swift b/reimburse/ExpenseTemplateSheet.swift index 461313f..27afefb 100644 --- a/reimburse/ExpenseTemplateSheet.swift +++ b/reimburse/ExpenseTemplateSheet.swift @@ -85,7 +85,8 @@ struct ExpenseTemplateManager: View { HStack(alignment: .top, spacing: 22) { Group { if let savedFile { - SpreadsheetPreview(url: savedFile).id(savedFile) + SpreadsheetFilePreview(url: savedFile, preferredSheet: store.expenseTemplate?.mapping.sheetName) + .id(savedFile) } else { ContentUnavailableView("当前使用内置报销单", systemImage: "tablecells", description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。")) @@ -161,7 +162,7 @@ private struct ExpenseTemplateEditor: View { HStack(alignment: .top, spacing: 22) { VStack(alignment: .leading, spacing: 8) { Text("原始模板预览").font(.caption).foregroundStyle(.secondary) - SpreadsheetPreview(url: draft.source).id(draft.source) + SpreadsheetFilePreview(url: draft.source, preferredSheet: mapping.sheetName).id(draft.source) .background(.white).clipShape(RoundedRectangle(cornerRadius: 10)) Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。") .font(.caption).foregroundStyle(.secondary) diff --git a/reimburse/NativeDiagnostics.swift b/reimburse/NativeDiagnostics.swift index 2858484..92fc5f8 100644 --- a/reimburse/NativeDiagnostics.swift +++ b/reimburse/NativeDiagnostics.swift @@ -131,12 +131,23 @@ 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-layout-v2", - let layoutObject = previewMetadata?["previewLayout"] else { + guard previewMetadata?["expensePreview"] as? String == "xlsx-layout-v4" else { throw EngineFailure.message("应用内报销单预览未使用新版引擎") } - let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: - JSONSerialization.data(withJSONObject: layoutObject)).validated() + let layout = try SpreadsheetLayout.decode(previewMetadata?["previewLayout"]) + let templatePreviewRequest: [String: Any] = [ + "operation": "preview-expense-template", + "templatePath": try templateStorage.file(for: savedTemplate).path + ] + let templateDataBefore = try Data(contentsOf: templateStorage.file(for: savedTemplate)) + let templatePreviewData = try await bridge.run( + request: JSONSerialization.data(withJSONObject: templatePreviewRequest)) { _, _ in } + let templateLayout = try JSONDecoder().decode(SpreadsheetLayout.self, from: templatePreviewData).validated() + guard !templateLayout.sheets.isEmpty, + templateDataBefore == (try Data(contentsOf: templateStorage.file(for: savedTemplate))) else { + throw EngineFailure.message("模板预览修改了原始模板") + } + report["templatePreviewReadOnly"] = "passed" let generatedData = try Data(contentsOf: customDestination) let prepared = PreparedExpensePreview( file: customDestination, requestDigest: try PreparedExpensePreview.digest(customRequest), diff --git a/reimburse/SpreadsheetLayout.swift b/reimburse/SpreadsheetLayout.swift index 3467dd2..d9b41d3 100644 --- a/reimburse/SpreadsheetLayout.swift +++ b/reimburse/SpreadsheetLayout.swift @@ -6,19 +6,23 @@ struct SpreadsheetLayout: Decodable { 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) && + static func decode(_ object: Any?) throws -> Self { + guard let object else { throw ExpenseTemplateFailure.message("报销单排版数据缺失,请重新生成预览。") } + return try JSONDecoder().decode(Self.self, from: JSONSerialization.data(withJSONObject: object)).validated() + } + + func validated() throws -> Self { + guard version == 4, !sheets.isEmpty, sheets.allSatisfy({ sheet in + sheet.width.isFinite && sheet.height.isFinite && sheet.width > 0 && sheet.height > 0 && + sheet.cells.allSatisfy { cell in + [cell.x, cell.y, cell.width, cell.height, cell.overflowLeft, + cell.overflowRight, cell.style.fontSize].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 + cell.style.fontSize > 0 && cell.overflowLeft >= 0 && cell.overflowLeft <= cell.x && + cell.overflowRight >= cell.x + cell.width && cell.overflowRight <= sheet.width + 0.01 && + cell.y + cell.height <= sheet.height + 0.01 } - }) else { - throw ExpenseTemplateFailure.message("报销单排版数据不完整,请重新生成预览。") - } + }) else { throw ExpenseTemplateFailure.message("报销单排版数据不完整,请重新生成预览。") } return self } } @@ -32,24 +36,16 @@ struct SpreadsheetSheet: Decodable { struct SpreadsheetCell: Decodable { let reference: String - let x: Double - let y: Double - let width: Double - let height: Double + let x, y, width, height: Double let text: String let style: SpreadsheetStyle - let overflowLeft: Double - let overflowRight: Double + let overflowLeft, 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) - } + var textClip: NSRect { NSRect(x: overflowLeft, y: y, width: overflowRight - overflowLeft, height: height) } } struct SpreadsheetBorder: Decodable { - let style: String - let color: String - + let style, color: String var width: CGFloat { switch style { case "hair": 0.5 @@ -63,31 +59,24 @@ struct SpreadsheetBorder: Decodable { 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 bold, italic, underline, strike: Bool + let color, fill, horizontal, vertical: String + let wrap, shrink: Bool let rotation: Int let indent: Double let borders: [String: SpreadsheetBorder] + let accounting: Bool @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" - } + // SimSun-ExtB is an extension font, not the installed Chinese Songti + // face. WPS uses a sans fallback for its unsupported base characters. + if lower.contains("extb") { fallback = "PingFang SC" } + else if lower.contains("simsun") || font.contains("宋") { fallback = "Songti SC" } + else if lower.contains("kai") || font.contains("楷") { fallback = "Kaiti SC" } + else { fallback = "Arial" } 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) } @@ -97,10 +86,7 @@ struct SpreadsheetStyle: Decodable { } enum SpreadsheetZoom: String, CaseIterable { - case width = "适合宽度" - case page = "整页" - case actual = "100%" - + case width = "适合宽度", page = "整页", 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 @@ -114,6 +100,8 @@ enum SpreadsheetZoom: String, CaseIterable { struct ExpenseSpreadsheetPreview: View { let layout: SpreadsheetLayout + var preferredSheet: String? + var onExpand: (() -> Void)? @State private var sheetIndex = 0 @State private var zoom: SpreadsheetZoom = .width @State private var customScale: CGFloat? @@ -121,49 +109,47 @@ struct ExpenseSpreadsheetPreview: View { 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 viewport = CGSize(width: geometry.size.width, height: max(1, geometry.size.height - 44)) let scale = customScale ?? zoom.scale(sheet: sheet, viewport: viewport) VStack(spacing: 0) { - HStack(spacing: 12) { + HStack(spacing: 10) { Picker("工作表", selection: $sheetIndex) { - ForEach(layout.sheets.indices, id: \.self) { index in - Text(layout.sheets[index].name).tag(index) - } - }.frame(maxWidth: 260) - Spacer(minLength: 8) + ForEach(layout.sheets.indices, id: \.self) { Text(layout.sheets[$0].name).tag($0) } + }.labelsHidden().frame(minWidth: 90, maxWidth: 200) + Spacer(minLength: 0) 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) + ForEach(SpreadsheetZoom.allCases, id: \.self) { Text($0.rawValue).tag(Optional($0)) } + }.pickerStyle(.segmented).labelsHidden().frame(width: 190) + 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) + .font(.callout.monospacedDigit()).frame(width: 45) + Button { customScale = min(2.5, scale + 0.1) } label: { + Image(systemName: "plus.magnifyingglass") + }.help("放大").disabled(scale >= 2.5) + if let onExpand { + Button(action: onExpand) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + }.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口") + } + }.padding(.horizontal, 16).frame(height: 44) .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) + .background(.white).padding(24) .frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top) + }.background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1))) + .id(sheetIndex) + }.onChange(of: sheetIndex) { _, _ in customScale = nil } + .onChange(of: preferredSheet, initial: true) { _, name in + if let name, let index = layout.sheets.firstIndex(where: { $0.name == name }) { + sheetIndex = index + } } - .background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1))) - .id(sheetIndex) - } - .onChange(of: zoom) { _, _ in customScale = nil } - .onChange(of: sheetIndex) { _, _ in customScale = nil } } } } @@ -171,11 +157,7 @@ struct ExpenseSpreadsheetPreview: View { struct SpreadsheetCanvas: NSViewRepresentable { let sheet: SpreadsheetSheet let scale: CGFloat - - func makeNSView(context: Context) -> SpreadsheetCanvasView { - SpreadsheetCanvasView(sheet: sheet, scale: scale) - } - + func makeNSView(context: Context) -> SpreadsheetCanvasView { SpreadsheetCanvasView(sheet: sheet, scale: scale) } func updateNSView(_ view: SpreadsheetCanvasView, context: Context) { view.sheet = sheet view.scale = scale @@ -184,11 +166,17 @@ struct SpreadsheetCanvas: NSViewRepresentable { } @MainActor final class SpreadsheetCanvasView: NSView { + struct TextPlacement { + let cell: SpreadsheetCell + let text: NSAttributedString + let box: NSRect + let overflow: NSRect? + } + 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 @@ -196,9 +184,91 @@ struct SpreadsheetCanvas: NSViewRepresentable { setAccessibilityLabel(sheet.name) setAccessibilityRole(.image) } - required init?(coder: NSCoder) { nil } + 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) + } + + func placement(for cell: SpreadsheetCell) -> TextPlacement { + let style = cell.style + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = style.accounting ? .right : + style.horizontal == "center" || style.horizontal == "centerContinuous" ? .center : + style.horizontal == "right" ? .right : .left + paragraph.lineBreakMode = style.wrap || style.rotation == 255 ? .byWordWrapping : .byClipping + var text = style.rotation == 255 ? cell.text.filter { !$0.isNewline }.map(String.init).joined(separator: "\n") : + style.accounting ? cell.text.trimmingCharacters(in: .whitespaces) : 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 = max(1, box.width - indent) + if style.accounting { box.size.width = max(1, box.width - font.pointSize * 0.25) } + 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.wrap && style.rotation == 0 && !text.contains("\n") { + // A trailing Latin caption is a separate wrapping run from the + // Chinese label. Keep it together when it fits on its own line. + let source = text as NSString + let suffix = source.range(of: #"[A-Za-z][\x20-\x7E]*$"#, options: .regularExpression) + if suffix.location != NSNotFound && suffix.location > 0 { + let prefix = source.substring(to: suffix.location) + let tail = source.substring(from: suffix.location) + if prefix.unicodeScalars.contains(where: { (0x3400...0x9FFF).contains($0.value) }), + tail.contains(" "), + source.size(withAttributes: attributes).width > box.width, + (prefix as NSString).size(withAttributes: attributes).width <= box.width, + (tail as NSString).size(withAttributes: attributes).width <= box.width { + text = prefix.trimmingCharacters(in: .whitespaces) + "\n" + tail + } + } + } + var natural = ceil((text as NSString).size(withAttributes: attributes).width) + if style.shrink && !style.wrap && style.rotation == 0 && natural > box.width { + font = style.resolvedFont(size: max(1, font.pointSize * box.width / natural)) + attributes[.font] = font + natural = box.width + } + var overflow: NSRect? + if cell.textClip.width > cell.width && 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 + overflow = NSRect(x: box.minX, y: cell.y, width: box.width, height: cell.height) + .intersection(cell.textClip) + } + let attributed = NSAttributedString(string: text, attributes: attributes) + let measured = attributed.boundingRect(with: NSSize(width: box.width, height: 100000), + options: [.usesLineFragmentOrigin, .usesFontLeading]) + 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 + return TextPlacement(cell: cell, text: attributed, box: box, overflow: overflow) + } + + // Overflowing text occupies adjacent empty cells. Suppress both copies of + // each crossed vertical edge for that row, not just pixels behind glyphs. + func verticalSegments(x: CGFloat, top: CGFloat, bottom: CGFloat, overflow: [NSRect]) -> [ClosedRange] { + var ranges = [top...bottom] + for rect in overflow where x > rect.minX && x < rect.maxX { + ranges = ranges.flatMap { range -> [ClosedRange] in + guard rect.maxY > range.lowerBound && rect.minY < range.upperBound else { return [range] } + var remaining: [ClosedRange] = [] + if rect.minY > range.lowerBound { remaining.append(range.lowerBound...rect.minY) } + if rect.maxY < range.upperBound { remaining.append(rect.maxY...range.upperBound) } + return remaining + } + } + return ranges + } + override func draw(_ dirtyRect: NSRect) { NSColor.white.setFill() dirtyRect.fill() @@ -209,104 +279,56 @@ struct SpreadsheetCanvas: NSViewRepresentable { 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. + let placements = cells.filter { !$0.text.isEmpty }.map { placement(for: $0) } + let overflow = placements.compactMap(\.overflow) 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 - } + // Strong shared edges win over thin edges regardless of XML cell order. + let edges = cells.flatMap { cell in cell.style.borders.map { (cell, $0.key, $0.value) } } + .sorted { $0.2.width < $1.2.width } + for (cell, edge, border) in edges { + drawBorder(cell, edge: edge, border: border, overflow: overflow, context: context) } - 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) - } + for placement in placements { 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)) - } + context.clip(to: placement.cell.textClip) + if (1...180).contains(placement.cell.style.rotation) { + let rotation = placement.cell.style.rotation + let angle = rotation <= 90 ? rotation : 90 - rotation + context.translateBy(x: placement.cell.rect.midX, y: placement.cell.rect.midY) + context.rotate(by: -CGFloat(angle) * .pi / 180) + placement.text.draw(at: NSPoint(x: -placement.box.width / 2, y: -placement.box.height / 2)) } else { - context.move(to: start) - context.addLine(to: end) + placement.text.draw(with: placement.box, options: [.usesLineFragmentOrigin, .usesFontLeading]) } - context.strokePath() context.restoreGState() } } + + private func drawBorder(_ cell: SpreadsheetCell, edge: String, border: SpreadsheetBorder, + overflow: [NSRect], context: CGContext) { + let rect = cell.rect + let vertical = edge == "left" || edge == "right" + let fixed = vertical ? (edge == "left" ? rect.minX : rect.maxX) : + (edge == "top" ? rect.minY : rect.maxY) + let ranges = vertical ? verticalSegments(x: fixed, top: rect.minY, bottom: rect.maxY, overflow: overflow) : + [rect.minX...rect.maxX] + context.saveGState() + defer { context.restoreGState() } + 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]) } + for range in ranges { + for offset in border.style == "double" ? [-1.0, 1.0] : [0.0] { + context.move(to: vertical ? CGPoint(x: fixed + offset, y: range.lowerBound) : + CGPoint(x: range.lowerBound, y: fixed + offset)) + context.addLine(to: vertical ? CGPoint(x: fixed + offset, y: range.upperBound) : + CGPoint(x: range.upperBound, y: fixed + offset)) + } + } + context.strokePath() + } } diff --git a/reimburse/SpreadsheetPreview.swift b/reimburse/SpreadsheetPreview.swift new file mode 100644 index 0000000..f105041 --- /dev/null +++ b/reimburse/SpreadsheetPreview.swift @@ -0,0 +1,143 @@ +import AppKit +import Combine +import SwiftUI + +struct SpreadsheetFilePreview: View { + let url: URL + var preferredSheet: String? + @State private var layout: SpreadsheetLayout? + @State private var error: String? + @State private var attempt = 0 + + private struct Request: Equatable { + let url: URL + let attempt: Int + } + + var body: some View { + Group { + if let layout { + ExpenseDocumentPreview(layout: layout, title: "报销单模板", preferredSheet: preferredSheet) + } else if let error { + VStack(spacing: 12) { + ContentUnavailableView("模板预览暂不可用", systemImage: "doc.badge.exclamationmark", + description: Text(error)) + Button("重新加载") { attempt += 1 } + } + } else { + ProgressView("正在读取模板预览…").frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task(id: Request(url: url, attempt: attempt)) { + layout = nil + error = nil + // Independent from the scan/import worker: dismissing a preview must + // not cancel an import or another workspace operation. + let engine = EngineBridge() + do { + try Task.checkCancellation() + let request = try JSONSerialization.data(withJSONObject: [ + "operation": "preview-expense-template", "templatePath": url.path + ]) + let data = try await withTaskCancellationHandler { + try await engine.run(request: request) { _, _ in } + } onCancel: { + engine.cancel() + } + try Task.checkCancellation() + let decoded = try JSONDecoder().decode(SpreadsheetLayout.self, from: data).validated() + layout = decoded + } catch { + guard !Task.isCancelled else { return } + self.error = error.localizedDescription + } + } + } +} + +struct ExpenseDocumentPreview: View { + let layout: SpreadsheetLayout + var title = "报销单预览" + var preferredSheet: String? + @StateObject private var expanded = SpreadsheetPreviewWindow() + + private var warnings: [String] { + var warnings = layout.warnings + let missing = Array(Set(layout.sheets.flatMap(\.cells).map(\.style.font))) + .filter { NSFont(name: $0, size: 12) == nil }.sorted() + if !missing.isEmpty { + warnings.append("本机缺少字体,预览已使用替代字体:\(missing.prefix(3).joined(separator: "、"))\(missing.count > 3 ? "等" : "")") + } + return warnings + } + + var body: some View { + VStack(spacing: 0) { + ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet) { + expanded.show(layout: layout, title: title, preferredSheet: preferredSheet) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white) + if !warnings.isEmpty { + Text(warnings.joined(separator: ";")) + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 8) + } + } + .onChange(of: preferredSheet) { _, _ in + expanded.update(layout: layout, title: title, preferredSheet: preferredSheet) + } + .onDisappear { expanded.close() } + } +} + +@MainActor +final class SpreadsheetPreviewWindow: NSObject, ObservableObject, NSWindowDelegate { + private(set) var window: NSWindow? + private var preview: NSHostingView? + + static func initialFrame(in screen: NSRect) -> NSRect { + let available = screen.insetBy(dx: 24, dy: 24) + let size = NSSize(width: min(1440, available.width), height: min(1000, available.height)) + return NSRect(x: available.midX - size.width / 2, y: available.midY - size.height / 2, + width: size.width, height: size.height) + } + + func show(layout: SpreadsheetLayout, title: String, preferredSheet: String? = nil) { + if let window { + update(layout: layout, title: title, preferredSheet: preferredSheet) + window.makeKeyAndOrderFront(nil) + return + } + let screen = NSApp.keyWindow?.screen ?? NSScreen.main + let frame = Self.initialFrame(in: screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)) + let window = NSWindow(contentRect: frame, styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.setFrame(frame, display: false) + window.minSize = NSSize(width: 640, height: 420) + window.collectionBehavior = [.fullScreenPrimary] + window.isReleasedWhenClosed = false + window.delegate = self + window.title = title + let preview = NSHostingView(rootView: ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet)) + preview.frame = NSRect(origin: .zero, size: frame.size) + preview.autoresizingMask = [.width, .height] + window.contentView = preview + self.window = window + self.preview = preview + window.makeKeyAndOrderFront(nil) + } + + func update(layout: SpreadsheetLayout, title: String, preferredSheet: String? = nil) { + window?.title = title + preview?.rootView = ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet) + } + + func close() { window?.close() } + + func windowWillClose(_ notification: Notification) { + preview = nil + window = nil + } +} diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index 759843a..ea4e0fb 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -447,12 +447,10 @@ 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-layout-v2", - let layoutObject = metadata?["previewLayout"] else { + guard metadata?["expensePreview"] as? String == "xlsx-layout-v4" else { throw ExpenseTemplateFailure.message("当前引擎不支持报销单预览,请重新构建应用。") } - let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: - JSONSerialization.data(withJSONObject: layoutObject)).validated() + let layout = try SpreadsheetLayout.decode(metadata?["previewLayout"]) expensePreview = PreparedExpensePreview( file: file, requestDigest: digest, fileDigest: ExpenseTemplateStorage.fingerprint(try Data(contentsOf: file)), diff --git a/tests/SpreadsheetLayoutTests.swift b/tests/SpreadsheetLayoutTests.swift index a38f5d3..49adfc3 100644 --- a/tests/SpreadsheetLayoutTests.swift +++ b/tests/SpreadsheetLayoutTests.swift @@ -4,51 +4,119 @@ 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() + _ = NSApplication.shared + NSApplication.shared.appearance = NSAppearance(named: .aqua) + let data = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1])) + let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: data).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)") + let cells = Dictionary(uniqueKeysWithValues: sheet.cells.map { ($0.reference, $0) }) + let canvas = SpreadsheetCanvasView(sheet: sheet, scale: 1) + let placements = sheet.cells.filter { !$0.text.isEmpty }.map { canvas.placement(for: $0) } + let overflow = placements.compactMap(\.overflow) + for ref in ["A30", "D30", "A31"] { + let cell = cells[ref]! + precondition(canvas.verticalSegments(x: cell.rect.maxX, top: cell.rect.minY, + bottom: cell.rect.maxY, overflow: overflow).isEmpty, + "A vertical line still crosses \(ref)") } + let boundary = cells["B30"]! + precondition(!canvas.verticalSegments(x: boundary.rect.maxX, top: boundary.rect.minY, + bottom: boundary.rect.maxY, overflow: overflow).isEmpty, + "An unrelated border was removed") + let anchor = cells["A30"]! + let shortLabel = SpreadsheetCell(reference: anchor.reference, x: anchor.x, y: anchor.y, + width: anchor.width, height: anchor.height, text: "签", + style: anchor.style, overflowLeft: anchor.overflowLeft, + overflowRight: anchor.overflowRight) + precondition(canvas.placement(for: shortLabel).overflow == nil) + let blockedLabel = SpreadsheetCell(reference: anchor.reference, x: anchor.x, y: anchor.y, + width: anchor.width, height: anchor.height, text: anchor.text, + style: anchor.style, overflowLeft: anchor.x, + overflowRight: anchor.x + anchor.width) + precondition(canvas.placement(for: blockedLabel).overflow == nil) + for ref in ["F27", "H29"] { + let placed = canvas.placement(for: cells[ref]!) + let paragraph = placed.text.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as! NSParagraphStyle + precondition(paragraph.alignment == .right) + precondition(placed.text.string == "447.00") + } + let signature = canvas.placement(for: cells["H30"]!) + precondition(canvas.placement(for: cells["A29"]!).text.string.contains("\nTOTAL PAYMENT (A)")) + precondition(signature.text.string == "收\n款\n人\n签\n字") + precondition(abs(signature.box.maxY - cells["H30"]!.rect.maxY) < 2) + precondition(cells["H30"]!.style.borders["right"]?.style == "medium") + for size in [CGSize(width: 800, height: 500), CGSize(width: 1200, height: 760)] { + let scale = SpreadsheetZoom.width.scale(sheet: sheet, viewport: size) + precondition(scale * sheet.width <= size.width - 48) + let page = SpreadsheetZoom.page.scale(sheet: sheet, viewport: size) + precondition(page * sheet.height <= size.height - 48) + } + let expanded = SpreadsheetPreviewWindow() + expanded.show(layout: layout, title: "Test") + let first = expanded.window! + precondition(first.styleMask.contains(.resizable)) + expanded.show(layout: layout, title: "Template") + precondition(expanded.window === first) + expanded.close() + precondition(expanded.window == nil) + print("Overflow borders, accounting alignment, stacked signature, merge edges, zoom and window cleanup passed") + + let folder = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let bitmap = try capture(canvas, to: folder.appendingPathComponent("sheet.png")) + let ratio = CGFloat(bitmap.pixelsWide) / canvas.bounds.width + func luminance(x: CGFloat, y: CGFloat) -> CGFloat { + let color = bitmap.colorAt(x: Int(x * ratio), y: Int(y * ratio))!.usingColorSpace(.deviceRGB)! + return (color.redComponent + color.greenComponent + color.blueComponent) / 3 + } + // Pixel checks away from glyphs catch border regressions in actual drawing, + // not just matching JSON models or two instances of the same renderer. + for ref in ["A30", "D30", "A31"] { + let cell = cells[ref]! + precondition(luminance(x: cell.rect.maxX, y: cell.rect.minY + 15) > 0.97) + } + precondition(luminance(x: boundary.rect.maxX, y: boundary.rect.minY + 15) < 0.9) + for width in [620, 820, 1200] { + let host = NSHostingView(rootView: ExpenseDocumentPreview(layout: layout)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 700), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + host.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + _ = try capture(host, to: folder.appendingPathComponent("preview-\(width).png")) + window.orderOut(nil) + } + if CommandLine.arguments.count > 3 { + let alternateData = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[3])) + let alternate = try JSONDecoder().decode(SpreadsheetLayout.self, from: alternateData).validated() + let host = NSHostingView(rootView: ExpenseDocumentPreview(layout: alternate, + title: "报销单模板", preferredSheet: "说明页")) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 500), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + host.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + _ = try capture(host, to: folder.appendingPathComponent("template-selected-sheet.png")) + precondition(containsCanvas(host, named: "说明页"), "Template sheet selection was ignored") + host.rootView = ExpenseDocumentPreview(layout: alternate, title: "报销单模板", preferredSheet: "横向费用单") + host.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + _ = try capture(host, to: folder.appendingPathComponent("template-alternate.png")) + precondition(containsCanvas(host, named: "横向费用单"), "Changing the mapping sheet did not change the preview") + window.orderOut(nil) + } + print("Rendered pixel checks passed; screenshots 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") - } + @MainActor static func containsCanvas(_ view: NSView, named: String) -> Bool { + if let canvas = view as? SpreadsheetCanvasView, canvas.sheet.name == named { return true } + return view.subviews.contains { containsCanvas($0, named: named) } + } + + @MainActor static func capture(_ view: NSView, to url: URL) throws -> NSBitmapImageRep { + let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds)! 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) + try bitmap.representation(using: .png, properties: [:])!.write(to: url) + return bitmap } }