This commit is contained in:
csj
2026-09-18 10:35:38 +08:00
parent 45ccfd69be
commit 967eaaec8a
13 changed files with 787 additions and 463 deletions
+3 -3
View File
@@ -44,7 +44,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
在工具栏点击“报销模板”,或点击材料扫描页、个人报销单弹窗中的“选择模板 / 查看、更换”。也可以直接从访达将一个 `.xlsx` 文件拖到模板卡片或模板窗口上方的虚线区域;拖入时高亮,处理中不接受重复导入。不接受文件夹、多个文件或网页链接,失败时原模板保持不变。 在工具栏点击“报销模板”,或点击材料扫描页、个人报销单弹窗中的“选择模板 / 查看、更换”。也可以直接从访达将一个 `.xlsx` 文件拖到模板卡片或模板窗口上方的虚线区域;拖入时高亮,处理中不接受重复导入。不接受文件夹、多个文件或网页链接,失败时原模板保持不变。
模板窗口左侧显示 Excel 原表,右侧显示识别到的工作表、明细范围和收款位置。核对后点击“确认位置,使用此模板”。不再默认展示整屏地址输入框;仅在识别不正确时打开“调整填写位置”。未确认的新模板不会覆盖正在使用的模板,可随时放弃更换。 模板窗口左侧显示 Excel 原表,右侧显示识别到的工作表、明细范围和收款位置。“查看 / 更换”、新导入确认页与报销单预览共用同一套表格渲染,不再使用系统 Quick Look;切换填写工作表时同步显示对应工作表。模板预览为独立只读操作,不需要勾选费用,不计算或回写原模板公式,加载失败可重试。核对后点击“确认位置,使用此模板”。仅在识别不正确时打开“调整填写位置”。未确认的新模板不会覆盖正在使用的模板,可随时放弃更换。
模板只保存本机副本,重启后仍有效,不修改导入的原始文件。仅影响个人报销单,不影响 PPT、行程表及勾选范围;仍按所选费用类型合并金额、单据数,每类型一行。保留模板样式、合并区域、打印设置、其他工作表和未映射内容;明细超出确认范围时复制报销工作表分页。合计和其他公式在 Excel / WPS 打开后重新计算。 模板只保存本机副本,重启后仍有效,不修改导入的原始文件。仅影响个人报销单,不影响 PPT、行程表及勾选范围;仍按所选费用类型合并金额、单据数,每类型一行。保留模板样式、合并区域、打印设置、其他工作表和未映射内容;明细超出确认范围时复制报销工作表分页。合计和其他公式在 Excel / WPS 打开后重新计算。
@@ -56,9 +56,9 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
### 导出前预览 ### 导出前预览
在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。应用先按当前模板生成实际 Excel,再该文件读取列宽、行高、合并区域、文字和边框样式进行原生表格预览。默认适合宽度且不自动放大,可切换整页、100%、手动缩放及工作表,不单独挤压列宽。可返回修改,确认无误后点击“确认并保存”选择位置;保存的字节与预览文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。
预览优先使用模板字体,未安装的字体使用本机同类字体替代。图片、图表、条件格式及部分特殊数字格式会提示在 Excel / WPS 中核对,不保证这些高级内容逐像素一致;模板导入窗口仍使用系统 Quick Look 跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 WPS 逐像素相同。预览限制不改变导出的模板样式
预览会刷新受支持公式的缓存(求和、同表引用、基本四则运算、条件判断、今日日期及内置人民币大写公式),保留公式本身。复杂自定义公式不猜测结果:移除旧缓存,明确列出尚未计算的单元格,由 Excel / WPS 打开后重算。预览属于屏幕表格展示,不是分页打印校样;打印设置保留在 Excel 中。 预览会刷新受支持公式的缓存(求和、同表引用、基本四则运算、条件判断、今日日期及内置人民币大写公式),保留公式本身。复杂自定义公式不猜测结果:移除旧缓存,明确列出尚未计算的单元格,由 Excel / WPS 打开后重算。预览属于屏幕表格展示,不是分页打印校样;打印设置保留在 Excel 中。
+3
View File
@@ -87,6 +87,9 @@ def dispatch(request):
return scan(request) return scan(request)
if operation == 'inspect-expense-template': if operation == 'inspect-expense-template':
return inspect_template(request['templatePath']) 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': if operation == 'validate-expense-template':
return validate_template(request['templatePath'], request['templateMapping']) return validate_template(request['templatePath'], request['templateMapping'])
state = request['state'] state = request['state']
+3 -2
View File
@@ -218,5 +218,6 @@ def prepare_preview(path):
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as output: with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as output:
for name, data in template.parts.items(): for name, data in template.parts.items():
output.writestr(name, data) output.writestr(name, data)
return dict(expensePreview='xlsx-layout-v2', previewUncalculatedCells=uncalculated, result = dict(expensePreview='xlsx-layout-v4', previewUncalculatedCells=uncalculated,
previewLayout=read_layout(path)) previewLayout=read_layout(path))
return result
+125 -125
View File
@@ -1,131 +1,128 @@
"""Read-only display geometry from the exact XLSX that will be saved.""" """Read-only workbook display model; never modify the exported workbook."""
import datetime
import math import math
import mimetypes import mimetypes
import re import re
from decimal import Decimal, ROUND_HALF_UP 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.knownfiles = []
mimetypes.init() mimetypes.init()
from openpyxl.styles.colors import COLOR_INDEX from openpyxl.styles.colors import COLOR_INDEX
from openpyxl.styles.numbers import BUILTIN_FORMATS, is_date_format from openpyxl.styles.numbers import BUILTIN_FORMATS, is_date_format
from openpyxl.utils.datetime import from_excel, MAC_EPOCH, WINDOWS_EPOCH from openpyxl.utils.datetime import from_excel, MAC_EPOCH, WINDOWS_EPOCH
from expense_template import Template, column_name, coordinate, tag, xml from expense_template import Template, column_name, coordinate, tag, xml
DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main'
MAX_CELLS = 50000 MAX_CELLS = 50000
DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def flag(node, name, default=False): def flag(node, key, default=False):
return node.get(name, '1' if default else '0') in ('1', 'true') 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)) 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): def theme_colors(template):
if 'xl/theme/theme1.xml' not in template.parts: defaults = ['FFFFFF', '000000', 'EEECE1', '1F497D', '4F81BD', 'C0504D',
return ['FFFFFF', '000000', 'EEECE1', '1F497D', '4F81BD', 'C0504D',
'9BBB59', '8064A2', '4BACC6', 'F79646', '0000FF', '800080'] '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') 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')) if scheme is None:
for entry in scheme} return defaults
return [by_name.get(name, '000000') for name in colors = {entry.tag.split('}')[-1]: entry[0].get('lastClr', entry[0].get('val', '000000'))
['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', for entry in scheme if len(entry)}
'accent5', 'accent6', 'hlink', 'folHlink']] 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: if node is None:
return fallback return default
value = node.get('rgb') value = node.get('rgb')
if value is None and 'theme' in node.attrib: if value is None and 'theme' in node.attrib:
value = palette[int(node.get('theme')) % len(palette)] value = palette[int(node.get('theme')) % len(palette)]
if value is None and 'indexed' in node.attrib: if value is None and 'indexed' in node.attrib:
index = int(node.get('indexed')) index = int(node.get('indexed'))
value = COLOR_INDEX[index] if index < len(COLOR_INDEX) else fallback value = COLOR_INDEX[index] if 0 <= index < len(COLOR_INDEX) else default
value = (value or fallback)[-6:] value = (value or default)[-6:]
if not re.fullmatch('[0-9a-fA-F]{6}', value): if not re.fullmatch('[0-9a-fA-F]{6}', value):
return fallback return default
tint = max(-1, min(1, float(node.get('tint', 0)))) 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}' return ''.join(f'{round(c * (1 + tint) if tint < 0 else c * (1 - tint) + 255 * tint):02X}'
for channel in (int(value[index:index + 2], 16) for index in (0, 2, 4))) for c in (int(value[i:i + 2], 16) for i in (0, 2, 4)))
def literal(value): def literal(value):
return re.sub(r'"([^"]*)"|\\(.)|_.|\*.', lambda m: m[1] or m[2] or '', 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): 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 return value
if kind == 'b': if kind == 'b':
return 'TRUE' if value == '1' else 'FALSE' return 'TRUE' if value == '1' else 'FALSE'
number = Decimal(value) number = Decimal(value)
if is_date_format(code): if is_date_format(code):
date = from_excel(float(number), MAC_EPOCH if date_1904 else WINDOWS_EPOCH) date = from_excel(float(number), MAC_EPOCH if date_1904 else WINDOWS_EPOCH)
if isinstance(date, (datetime.datetime, datetime.time)): section = literal(re.sub(r'\[[^\]]*\]', '', code.split(';')[0])).lower()
# Excel date/time tokens are not strftime tokens (notably month/minute). if section == 'mm-dd-yy':
section = code.split(';')[0] return date.strftime('%m/%d/%y')
section = re.sub(r'\[[^\]]*\]', '', section) if re.fullmatch(r'yyyy([/.-])m{1,2}\1d{1,2}', section):
section = literal(section) return (str(date.year) + section[4] + (f'{date.month:02d}' if 'mm' in section else str(date.month))
if isinstance(date, datetime.datetime) and section.lower() == 'mm-dd-yy': + section[4] + (f'{date.day:02d}' if 'dd' in section else str(date.day)))
return date.strftime('%m-%d-%y') if section == 'yyyy年m月d日':
if re.fullmatch(r'yyyy([/.-])m{1,2}\1d{1,2}', section, re.I): return f'{date.year}{date.month}{date.day}'
sep = section[4] if section in ('h:mm', 'hh:mm', 'h:mm:ss', 'hh:mm:ss'):
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}' return date.strftime('%H:%M:%S' if 'ss' in section else '%H:%M')
if section.lower() in ('yyyy""m""d""', 'yyyy年m月d日'): warnings.add('特殊日期格式请在办公软件中核对')
return f'{date.year}{date.month}{date.day}' return date.date().isoformat() if hasattr(date, 'date') else date.isoformat()
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', '@'): if code.lower() in ('general', '@'):
return format(number, 'f').rstrip('0').rstrip('.') if '.' in str(number) else str(number) return format(number, 'f').rstrip('0').rstrip('.') if '.' in str(number) else str(number)
sections = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', code) section = number_section(number, 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) section = re.sub(r'\[\$([^\]-]*)[^\]]*\]', lambda m: m[1], section)
if re.search(r'\[(?:[<>=]|\d)', section): if re.search(r'\[(?:[<>=]|\d)', section):
warnings.add('部分条件数字格式使用原始数值显示') warnings.add('条件数字格式请在办公软件中核对')
return str(number) return str(number)
section = re.sub(r'\[[^\]]*\]', '', section) section = re.sub(r'\[[^\]]*\]', '', section)
# Ignore placeholders inside quoted/escaped literals.
masked = re.sub(r'"[^"]*"|\\.|_.|\*.', lambda m: ' ' * len(m[0]), section) masked = re.sub(r'"[^"]*"|\\.|_.|\*.', lambda m: ' ' * len(m[0]), section)
match = re.search(r'[#0][#0,]*(?:\.[#0?]+)?%?', masked) match = re.search(r'[#0][#0,]*(?:\.[#0?]+)?%?', masked)
if not match: if not match:
return literal(section).strip().replace('?', '') return literal(section).strip().replace('?', '')
if re.search(r'[Ee][+-]|[/]', masked) or masked[match.end():].lstrip().startswith(','): if re.search(r'[Ee][+-]|[/]', masked) or masked[match.end():].lstrip().startswith(','):
warnings.add('部分科学计数或分数格式使用原始数值显示') warnings.add('特殊数字格式请在办公软件中核对')
return str(number) return str(number)
pattern = match[0] pattern = match[0]
places = len(pattern.rstrip('%').split('.')[1]) if '.' in pattern else 0 fraction = pattern.rstrip('%').split('.')[1] if '.' in pattern else ''
value = abs(number) if number < 0 and len(sections) > 1 else number number = abs(number) if number < 0 and len(code.split(';')) > 1 else number
if '%' in pattern: if '%' in pattern:
value *= 100 number *= 100
value = value.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP) number = number.quantize(Decimal(1).scaleb(-len(fraction)), rounding=ROUND_HALF_UP)
text = format(value, (',' if ',' in pattern else '') + f'.{places}f') text = format(number, (',' if ',' in pattern else '') + f'.{len(fraction)}f')
if '.' in pattern: for _ in range(len(fraction) - len(fraction.rstrip('#?'))):
fraction = pattern.rstrip('%').split('.')[1] if text.endswith('0'):
optional = len(fraction) - len(fraction.rstrip('#?')) text = text[:-1]
for _ in range(optional): if fraction:
if text.endswith('0'):
text = text[:-1]
text = text.rstrip('.') text = text.rstrip('.')
if ',' not in pattern and '.' not in pattern: if ',' not in pattern and '.' not in pattern:
text = text.zfill(pattern.count('0')) text = text.zfill(pattern.count('0'))
if '%' in pattern: return literal(section[:match.start()]) + text + ('%' if '%' in pattern else '') + literal(section[match.end():])
text += '%'
return literal(section[:match.start()]) + text + literal(section[match.end():])
def read_styles(template, palette): def read_styles(template, palette):
root = xml(template.parts['xl/styles.xml']) 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) formats = dict(BUILTIN_FORMATS)
for item in root.findall(tag('numFmts') + '/' + tag('numFmt')): for item in root.findall(tag('numFmts') + '/' + tag('numFmt')):
formats[int(item.get('numFmtId'))] = item.get('formatCode') 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'), horizontal=alignment.get('horizontal', 'general'), vertical=alignment.get('vertical', 'bottom'),
wrap=alignment.get('wrapText') in ('1', 'true'), shrink=alignment.get('shrinkToFit') in ('1', 'true'), 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)), 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 return styles
def read_layout(path): def read_layout(path):
template = Template(path) template = Template(path)
palette = theme_colors(template) styles = read_styles(template, theme_colors(template))
styles = read_styles(template, palette)
properties = template.workbook.find(tag('workbookPr')) properties = template.workbook.find(tag('workbookPr'))
date_1904 = properties is not None and flag(properties, 'date1904') date_1904 = properties is not None and flag(properties, 'date1904')
warnings = set() sheets, warnings, total_cells = [], set(), 0
sheets, total_cells = [], 0
for sheet_index, sheet in enumerate(template.sheets): for sheet_index, sheet in enumerate(template.sheets):
if sheet.get('state', 'visible') != 'visible': if sheet.get('state', 'visible') != 'visible':
continue continue
name = sheet.get('name') name = sheet.get('name')
_, _, document = template.sheet(name) _, _, document = template.sheet(name)
cells = {cell.get('r'): cell for cell in document.iter(tag('c'))} cells = {c.get('r'): c for c in document.iter(tag('c'))}
merges = [item.get('ref') for item in document.iter(tag('mergeCell'))] merges = [m.get('ref') for m in document.iter(tag('mergeCell'))]
# Empty formatted tail columns must not squeeze the actual form. Preserve references = [ref for ref, c in cells.items() if any(c.find(tag(k)) is not None for k in ('v', 'is', 'f'))]
# 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')): for definition in template.workbook.findall(tag('definedNames') + '/' + tag('definedName')):
if definition.get('name') == '_xlnm.Print_Area' and definition.get('localSheetId') == str(sheet_index): 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 += [c + r for _, _, c, r in re.findall(
references += [f'{c}{r}' for a, b, c, r in ranges] r'\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)', definition.text or '')]
references += [entry.split(':')[-1] for entry in merges] references += [m.split(':')[-1] for m in merges]
if not references: positions = [coordinate(ref) for ref in references or list(cells) or ['A1']]
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) 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('模板范围过大,无法完整预览;请精简模板后重试') raise ValueError('模板范围过大,无法完整预览;请精简模板后重试')
total_cells += len(cells)
defaults = document.find(tag('sheetFormatPr')) defaults = document.find(tag('sheetFormatPr'))
default_width = float(defaults.get('defaultColWidth', 8.43)) if defaults is not None else 8.43 dw = 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 dh = float(defaults.get('defaultRowHeight', 15)) if defaults is not None else 15
widths = [default_width] * columns widths, heights = [dw] * columns, [dh] * rows
heights = [default_height] * rows column_styles, row_styles = {}, {}
for column in document.findall(tag('cols') + '/' + tag('col')): for column in document.findall(tag('cols') + '/' + tag('col')):
for index in range(int(column.get('min')) - 1, min(columns, int(column.get('max')))): 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', default_width)) 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')): for row in document.findall(tag('sheetData') + '/' + tag('row')):
index = int(row.get('r')) - 1 index = int(row.get('r')) - 1
if index < rows: if 0 <= index < rows:
heights[index] = 0 if flag(row, 'hidden') else float(row.get('ht', default_height)) heights[index] = 0 if flag(row, 'hidden') else float(row.get('ht', dh))
# OOXML width is expressed in maximum-digit units. Geometry is kept at if flag(row, 'customFormat'):
# 96 dpi; Swift scales the complete sheet, never individual columns. row_styles[index + 1] = int(row.get('s', 0))
widths = [math.floor(((256 * width + 18) / 256) * 7) for width in widths]
heights = [height * 4 / 3 for height in heights]
xs, ys = [0], [0] xs, ys = [0], [0]
for width in widths: for width in widths:
xs.append(xs[-1] + width) xs.append(xs[-1] + math.floor(((256 * width + 18) / 256) * 7))
for height in heights: for height in heights:
ys.append(ys[-1] + height) ys.append(ys[-1] + height * 4 / 3)
covered, spans = set(), {} covered, spans = set(), {}
for merged in merges: for merged in merges:
first, last = merged.split(':') first, last = merged.split(':')
left, top = coordinate(first) left, top = coordinate(first)
right, bottom = coordinate(last) right, bottom = coordinate(last)
spans[first] = (right, bottom) spans[first] = (right, bottom)
covered.update((col, row) for col in range(left, right + 1) for row in range(top, bottom + 1) covered.update((c, r) for c in range(left, right + 1) for r in range(top, bottom + 1)
if (col, row) != (left, top)) if (c, r) != (left, top))
output = []
occupied = {coordinate(ref) for ref, cell in cells.items() def style_at(ref):
if cell.find(tag('v')) is not None or cell.find(tag('is')) is not None col, row = coordinate(ref)
or cell.find(tag('f')) is not None} 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(covered)
occupied.update(coordinate(ref) for ref in spans) occupied.update(coordinate(ref) for ref in spans)
output = []
for reference, cell in cells.items(): for reference, cell in cells.items():
col, row = coordinate(reference) col, row = coordinate(reference)
if col > columns or row > rows or (col, row) in covered: if col > columns or row > rows or (col, row) in covered:
continue continue
right, bottom = spans.get(reference, (col, row)) right, bottom = spans.get(reference, (col, row))
style = int(cell.get('s', 0)) appearance = dict(style_at(reference))
appearance = dict(styles[style])
appearance['borders'] = dict(appearance['borders']) appearance['borders'] = dict(appearance['borders'])
# Merged right/bottom edges may be stored on the last cell. if reference in spans:
for edge, edge_ref in [('right', column_name(right) + str(row)), # Use the perimeter, not the anchor's former internal edges.
('bottom', column_name(col) + str(bottom)), for edge, edge_ref in [('right', column_name(right) + str(row)),
('right', column_name(right) + str(bottom)), ('bottom', column_name(col) + str(bottom)),
('bottom', column_name(right) + str(bottom))]: ('right', column_name(right) + str(bottom)),
if edge not in appearance['borders'] and edge_ref in cells: ('bottom', column_name(right) + str(bottom))]:
candidate = styles[int(cells[edge_ref].get('s', 0))]['borders'].get(edge) candidate = style_at(edge_ref)['borders'].get(edge)
if candidate: if candidate:
appearance['borders'][edge] = candidate appearance['borders'][edge] = candidate
kind = cell.get('t', 'n') kind = cell.get('t', 'n')
@@ -247,26 +240,33 @@ def read_layout(path):
if kind == 's': if kind == 's':
value = template.shared[int(value)] if value else '' value = template.shared[int(value)] if value else ''
elif kind == 'inlineStr': 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) 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': if appearance['horizontal'] == 'general':
appearance['horizontal'] = 'right' if kind == 'n' else 'center' if kind == 'b' else 'left' 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] width, height = xs[right] - xs[col - 1], ys[bottom] - ys[row - 1]
if width > 0 and height > 0: if width <= 0 or height <= 0:
left_limit, right_limit = col, right continue
if (text and kind in ('s', 'inlineStr', 'str') and reference not in spans left_limit, right_limit = col, right
and not appearance['wrap'] and not appearance['shrink'] and appearance['rotation'] == 0): if (text and kind in ('s', 'inlineStr', 'str') and reference not in spans
if appearance['horizontal'] in ('left', 'center'): and not appearance['wrap'] and not appearance['shrink'] and appearance['rotation'] == 0):
while right_limit < columns and (right_limit + 1, row) not in occupied: if appearance['horizontal'] in ('left', 'center'):
right_limit += 1 while right_limit < columns and (right_limit + 1, row) not in occupied:
if appearance['horizontal'] in ('right', 'center'): right_limit += 1
while left_limit > 1 and (left_limit - 1, row) not in occupied: if appearance['horizontal'] in ('right', 'center'):
left_limit -= 1 while left_limit > 1 and (left_limit - 1, row) not in occupied:
output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height, left_limit -= 1
text=text, style=appearance, output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height,
overflowLeft=xs[left_limit - 1], overflowRight=xs[right_limit])) text=text, style=appearance, overflowLeft=xs[left_limit - 1],
overflowRight=xs[right_limit]))
for feature in ('drawing', 'legacyDrawing', 'conditionalFormatting'): for feature in ('drawing', 'legacyDrawing', 'conditionalFormatting'):
if document.find(tag(feature)) is not None: if document.find(tag(feature)) is not None:
warnings.add('模板中的图片、图表或条件格式请在 Excel / WPS 中核对') warnings.add('模板中的图片、图表或条件格式请在 Excel / WPS 中核对')
sheets.append(dict(name=name, width=max(1, xs[-1]), height=max(1, ys[-1]), cells=output)) sheets.append(dict(name=name, width=max(1, xs[-1]), height=max(1, ys[-1]), cells=output))
return dict(version=1, sheets=sheets, warnings=sorted(warnings)) return dict(version=4, sheets=sheets, warnings=sorted(warnings))
+58 -3
View File
@@ -12,7 +12,7 @@ from openpyxl import load_workbook
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_expense_template as fixtures import test_expense_template as fixtures
from expense_preview import PreviewCalculator, UnsupportedFormula, builtin_rmb, prepare_preview 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): 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(formulas.active['H29'].value, '=IF(F27>C27,F27-C27,0)')
self.assertEqual(result['previewUncalculatedCells'], []) self.assertEqual(result['previewUncalculatedCells'], [])
self.assertEqual(sheet['D29'].value, '伍佰肆拾肆元叁角整') self.assertEqual(sheet['D29'].value, '伍佰肆拾肆元叁角整')
self.assertEqual(result['expensePreview'], 'xlsx-layout-v2') self.assertEqual(result['expensePreview'], 'xlsx-layout-v4')
values.close() values.close()
formulas.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): def test_builtin_uppercase_amount_formula(self):
for amount, expected in [('3491.86', '叁仟肆佰玖拾壹元捌角陆分'), ('10001.01', '壹万零壹元零壹分'), for amount, expected in [('3491.86', '叁仟肆佰玖拾壹元捌角陆分'), ('10001.01', '壹万零壹元零壹分'),
('100000001', '壹亿零壹元整'), ('100010000', '壹亿零壹万元整'), ('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) 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) 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') 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.assertTrue(result['previewLayout']['sheets'])
self.assertNotIn('templatePreviewLayout', result)
self.assertEqual(result['expenseRowCount'], 2) self.assertEqual(result['expenseRowCount'], 2)
workbook = load_workbook(output, data_only=True) workbook = load_workbook(output, data_only=True)
self.assertEqual(workbook.active['F27'].value, 544.3) self.assertEqual(workbook.active['F27'].value, 544.3)
@@ -114,6 +153,22 @@ class ExpensePreviewTests(unittest.TestCase):
self.assertIn(workbook.active['C23'].value, ('', None)) self.assertIn(workbook.active['C23'].value, ('', None))
workbook.close() 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+128 -79
View File
@@ -1,16 +1,15 @@
import json import json
import os import os
from pathlib import Path from pathlib import Path
import sys
import subprocess import subprocess
import sys
import unittest import unittest
from lxml import etree as ET from lxml import etree as ET
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import test_expense_template as fixtures 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_preview import prepare_preview
from expense_template import Template, encoded, tag
from spreadsheet_layout import formatted, read_layout from spreadsheet_layout import formatted, read_layout
@@ -20,93 +19,143 @@ class SpreadsheetLayoutTests(unittest.TestCase):
self.fixture.setUp() self.fixture.setUp()
self.addCleanup(self.fixture.tearDown) self.addCleanup(self.fixture.tearDown)
def test_fresh_import_does_not_read_system_mime_database(self): def test_wps_signature_and_accounting_regressions(self):
code = """ source = Path(os.environ.get('RECEIPT_PREVIEW_SOURCE', self.fixture.template))
import sys original = source.read_bytes()
def audit(event, args): mapping = inspect_template(source)['sheets'][0]['mapping']
if event == 'open' and str(args[0]).endswith('mime.types'): output = self.fixture.root / 'preview.xlsx'
raise PermissionError('System MIME database is unavailable in the app sandbox') export_custom_expense(dict(matches=[self.fixture.match('changed', '交通', '447')]),
sys.addaudithook(audit) output, source, mapping, {},
import spreadsheet_layout ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人'],
""" dict(recipient='测试收款人', bankName='测试银行', accountNumber='001234567890'), {})
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) result = prepare_preview(output)
before = output.read_bytes() before = output.read_bytes()
layout = read_layout(output) 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']) self.assertEqual(layout, result['previewLayout'])
sheet = layout['sheets'][0] self.assertNotIn('templatePreviewLayout', result)
cells = {cell['reference']: cell for cell in sheet['cells']} cells = {c['reference']: c for c in layout['sheets'][0]['cells']}
self.assertEqual(cells['C23']['text'], '测试收款人') for ref, label in [('A30', '部门长'), ('D30', '剧组出纳'), ('A31', '制片主任'), ('D31', '剧组会计')]:
self.assertEqual(cells['C25']['text'], '001234567890') self.assertEqual(cells[ref]['text'], label + '')
self.assertEqual(cells['F27']['text'].strip(), '544.30') self.assertGreater(cells[ref]['overflowRight'], cells[ref]['x'] + cells[ref]['width'])
self.assertEqual(cells['D29']['text'], '伍佰肆拾肆元叁角整') self.assertEqual(cells['A30']['overflowRight'], cells['D30']['x'])
self.assertNotIn('D23', cells)
self.assertAlmostEqual(cells['C23']['x'] + cells['C23']['width'], sheet['width'])
self.assertEqual(cells['H30']['style']['rotation'], 255) self.assertEqual(cells['H30']['style']['rotation'], 255)
self.assertGreater(cells['A30']['overflowRight'], cells['A30']['x'] + cells['A30']['width']) self.assertEqual(cells['H30']['style']['vertical'], 'bottom')
self.assertLessEqual(cells['A30']['overflowRight'], cells['D30']['x']) self.assertEqual(cells['H30']['style']['borders']['right']['style'], 'medium')
_, _, document = Template(output).sheet(sheet['name']) self.assertEqual(cells['H30']['style']['borders']['bottom']['style'], 'medium')
row = document.find(tag('sheetData') + '/' + tag('row') + '[@r="30"]') for ref in ['F27', 'H29']:
self.assertAlmostEqual(cells['A30']['height'], float(row.get('ht')) * 4 / 3) self.assertTrue(cells[ref]['style']['accounting'])
self.assertAlmostEqual(cells['H30']['height'], sum(cells[ref]['height'] for ref in ['A30', 'A31', 'A32'])) self.assertEqual(cells[ref]['text'].strip(), '447.00')
self.assertEqual(cells['F27']['style']['horizontal'], 'center') self.assertTrue(cells[ref]['style']['bold'])
self.assertEqual(cells['F27']['style']['font'], 'SimSun-ExtB') self.assertNotIn('left', cells['D31']['style']['borders'])
self.assertEqual(cells['C23']['style']['borders']['right']['style'], 'medium') self.assertEqual(cells['C25']['text'], '001234567890')
self.assertLess(sheet['width'], 1200)
self.assertGreater(sheet['height'], 1200)
if os.environ.get('RECEIPT_PREVIEW_FIXTURE_DIR'): if os.environ.get('RECEIPT_PREVIEW_FIXTURE_DIR'):
folder = Path(os.environ['RECEIPT_PREVIEW_FIXTURE_DIR']) folder = Path(os.environ['RECEIPT_PREVIEW_FIXTURE_DIR'])
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
(folder / 'layout.json').write_text(json.dumps(layout, ensure_ascii=False), encoding='utf-8') (folder / 'layout.json').write_text(json.dumps(layout, ensure_ascii=False), encoding='utf-8')
(folder / 'preview.xlsx').write_bytes(before) (folder / 'preview.xlsx').write_bytes(before)
def test_all_visible_sheets_and_pagination_remain_available(self): def test_overflow_stops_at_text_and_merged_cells(self):
template, mapping = self.fixture.alternate() output, _ = self.fixture.export()
state = dict(matches=self.fixture.state['matches'] + [self.fixture.match('third', '住宿', '50')]) template = Template(output)
output, _ = self.fixture.export(mapping=mapping, template=template, state=state) _, path, document = template.sheet('个人报销单')
layout = prepare_preview(output)['previewLayout'] set_cell(document, 'B30', '阻止覆盖')
self.assertEqual([sheet['name'] for sheet in layout['sheets']], ['组 B 报销', '保留说明', '组 B 报销-续2']) template.parts[path] = encoded(document)
notes = layout['sheets'][1] self.fixture.rewrite(output, template.parts)
self.assertEqual(notes['cells'][0]['text'], '保持这个工作表不变') cells = {c['reference']: c for c in read_layout(output)['sheets'][0]['cells']}
self.assertTrue(all(sheet['width'] > 0 and sheet['height'] > 0 for sheet in layout['sheets'])) 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() output, _ = self.fixture.export()
template = Template(output) template = Template(output)
_, path, document = template.sheet('个人报销单') _, path, document = template.sheet('个人报销单')
document.find(tag('cols'))[0].set('hidden', '1') document.find(tag('cols'))[0].set('hidden', '1')
row = document.find(tag('sheetData'))[0] document.find(tag('sheetData'))[0].set('hidden', '1')
row.set('hidden', '1') set_cell(document, 'J36', '打印范围外')
from expense_template import set_cell
set_cell(document, 'J36', '不能遗漏打印区域以外的内容')
template.parts[path] = encoded(document) template.parts[path] = encoded(document)
self.fixture.rewrite(output, template.parts) self.fixture.rewrite(output, template.parts)
sheet = read_layout(output)['sheets'][0] cells = {c['reference']: c for c in read_layout(output)['sheets'][0]['cells']}
cells = {cell['reference']: cell for cell in sheet['cells']}
self.assertNotIn('A9', cells) self.assertNotIn('A9', cells)
self.assertNotIn('A1', cells)
self.assertEqual(cells['B9']['x'], 0) self.assertEqual(cells['B9']['x'], 0)
self.assertIn('J36', cells) self.assertIn('J36', cells)
def test_oversized_range_fails_instead_of_truncating(self): def test_large_range_rejected(self):
output, _ = self.fixture.export() output, _ = self.fixture.export()
template = Template(output) template = Template(output)
_, path, document = template.sheet('个人报销单') _, path, document = template.sheet('个人报销单')
from expense_template import set_cell set_cell(document, 'IV2000', 'range')
set_cell(document, 'IV2000', 'too large')
template.parts[path] = encoded(document) template.parts[path] = encoded(document)
self.fixture.rewrite(output, template.parts) self.fixture.rewrite(output, template.parts)
with self.assertRaisesRegex(ValueError, '范围过大'): with self.assertRaisesRegex(ValueError, '范围过大'):
read_layout(output) read_layout(output)
def test_drawings_and_conditional_formatting_have_explicit_warning(self): def test_advanced_features_warn(self):
output, _ = self.fixture.export() output, _ = self.fixture.export()
template = Template(output) template = Template(output)
_, path, document = template.sheet('个人报销单') _, path, document = template.sheet('个人报销单')
@@ -115,29 +164,29 @@ import spreadsheet_layout
self.fixture.rewrite(output, template.parts) self.fixture.rewrite(output, template.parts)
self.assertTrue(read_layout(output)['warnings']) self.assertTrue(read_layout(output)['warnings'])
def test_number_formats_and_text_identity(self): def test_numeric_and_date_formatting(self):
warnings = set() warnings = set()
for value, kind, code, expected in [ for value, code, expected in [('447', '_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)', '447.00'),
('2122.60', 'n', '#,##0.00', '2,122.60'), ('2122.6', '#,##0.00', '2,122.60'), ('-25.3', '0.00;(0.00)', '(25.30)'),
('662.8', 'n', '0.00_ ', '662.80'), ('12.50', '0.##', '12.5'), ('12', '0.0#', '12.0'),
('0', 'n', '#,##0.00;(#,##0.00);"-"', '-'), ('8', '0000', '0008'), ('0.125', '0.0%', '12.5%')]:
('-25.3', 'n', '#,##0.00;(#,##0.00)', '(25.30)'), self.assertEqual(formatted(value, 'n', code, False, warnings).strip(), expected)
('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.assertEqual(formatted('1', 'n', 'yyyy/m/d', True, warnings), '1904/1/2')
self.assertFalse(warnings) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+3 -30
View File
@@ -1,27 +1,4 @@
import SwiftUI 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 { struct ExpensePreviewSheet: View {
@EnvironmentObject var store: WorkspaceStore @EnvironmentObject var store: WorkspaceStore
@@ -47,7 +24,7 @@ struct ExpensePreviewSheet: View {
Button("取消生成", action: store.cancel) Button("取消生成", action: store.cancel)
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let preview = store.expensePreview, let layout = preview.layout { } 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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ContentUnavailableView("暂时无法生成预览", systemImage: "doc.badge.ellipsis", ContentUnavailableView("暂时无法生成预览", systemImage: "doc.badge.ellipsis",
@@ -63,11 +40,6 @@ struct ExpensePreviewSheet: View {
.font(.caption).foregroundStyle(.orange) .font(.caption).foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 20).padding(.bottom, 10) .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() Divider()
HStack { HStack {
Text("确认后保存此报销单") Text("确认后保存此报销单")
@@ -82,7 +54,8 @@ struct ExpensePreviewSheet: View {
.disabled(store.expensePreview == nil || store.expensePreviewError != nil) .disabled(store.expensePreview == nil || store.expensePreviewError != nil)
}.padding(20).disabled(store.busy) }.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) .interactiveDismissDisabled(store.busy)
.onDisappear { store.discardExpensePreview() } .onDisappear { store.discardExpensePreview() }
} }
+3 -2
View File
@@ -85,7 +85,8 @@ struct ExpenseTemplateManager: View {
HStack(alignment: .top, spacing: 22) { HStack(alignment: .top, spacing: 22) {
Group { Group {
if let savedFile { if let savedFile {
SpreadsheetPreview(url: savedFile).id(savedFile) SpreadsheetFilePreview(url: savedFile, preferredSheet: store.expenseTemplate?.mapping.sheetName)
.id(savedFile)
} else { } else {
ContentUnavailableView("当前使用内置报销单", systemImage: "tablecells", ContentUnavailableView("当前使用内置报销单", systemImage: "tablecells",
description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。")) description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。"))
@@ -161,7 +162,7 @@ private struct ExpenseTemplateEditor: View {
HStack(alignment: .top, spacing: 22) { HStack(alignment: .top, spacing: 22) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("原始模板预览").font(.caption).foregroundStyle(.secondary) 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)) .background(.white).clipShape(RoundedRectangle(cornerRadius: 10))
Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。") Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
+15 -4
View File
@@ -131,12 +131,23 @@ enum NativeDiagnostics {
try ExpenseSummary.validateExportResult(customResult, matches: groupedState.matches) try ExpenseSummary.validateExportResult(customResult, matches: groupedState.matches)
try ExpenseTemplateStorage.validateExport(customResult, template: savedTemplate) try ExpenseTemplateStorage.validateExport(customResult, template: savedTemplate)
let previewMetadata = try JSONSerialization.jsonObject(with: customResult) as? [String: Any] let previewMetadata = try JSONSerialization.jsonObject(with: customResult) as? [String: Any]
guard previewMetadata?["expensePreview"] as? String == "xlsx-layout-v2", guard previewMetadata?["expensePreview"] as? String == "xlsx-layout-v4" else {
let layoutObject = previewMetadata?["previewLayout"] else {
throw EngineFailure.message("应用内报销单预览未使用新版引擎") throw EngineFailure.message("应用内报销单预览未使用新版引擎")
} }
let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: let layout = try SpreadsheetLayout.decode(previewMetadata?["previewLayout"])
JSONSerialization.data(withJSONObject: layoutObject)).validated() 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 generatedData = try Data(contentsOf: customDestination)
let prepared = PreparedExpensePreview( let prepared = PreparedExpensePreview(
file: customDestination, requestDigest: try PreparedExpensePreview.digest(customRequest), file: customDestination, requestDigest: try PreparedExpensePreview.digest(customRequest),
+192 -170
View File
@@ -6,19 +6,23 @@ struct SpreadsheetLayout: Decodable {
let sheets: [SpreadsheetSheet] let sheets: [SpreadsheetSheet]
let warnings: [String] let warnings: [String]
func validated() throws -> SpreadsheetLayout { static func decode(_ object: Any?) throws -> Self {
guard version == 1, !sheets.isEmpty, sheets.allSatisfy({ guard let object else { throw ExpenseTemplateFailure.message("报销单排版数据缺失,请重新生成预览。") }
$0.width.isFinite && $0.height.isFinite && $0.width > 0 && $0.height > 0 && return try JSONDecoder().decode(Self.self, from: JSONSerialization.data(withJSONObject: object)).validated()
$0.cells.allSatisfy { cell in }
[cell.x, cell.y, cell.width, cell.height, cell.style.fontSize,
cell.overflowLeft, cell.overflowRight].allSatisfy(\.isFinite) && 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.x >= 0 && cell.y >= 0 && cell.width > 0 && cell.height > 0 &&
cell.style.fontSize > 0 && cell.overflowLeft >= 0 && cell.style.fontSize > 0 && cell.overflowLeft >= 0 && cell.overflowLeft <= cell.x &&
cell.overflowLeft <= cell.x && cell.overflowRight >= cell.x + cell.width cell.overflowRight >= cell.x + cell.width && cell.overflowRight <= sheet.width + 0.01 &&
cell.y + cell.height <= sheet.height + 0.01
} }
}) else { }) else { throw ExpenseTemplateFailure.message("报销单排版数据不完整,请重新生成预览。") }
throw ExpenseTemplateFailure.message("报销单排版数据不完整,请重新生成预览。")
}
return self return self
} }
} }
@@ -32,24 +36,16 @@ struct SpreadsheetSheet: Decodable {
struct SpreadsheetCell: Decodable { struct SpreadsheetCell: Decodable {
let reference: String let reference: String
let x: Double let x, y, width, height: Double
let y: Double
let width: Double
let height: Double
let text: String let text: String
let style: SpreadsheetStyle let style: SpreadsheetStyle
let overflowLeft: Double let overflowLeft, overflowRight: Double
let overflowRight: Double
var rect: NSRect { NSRect(x: x, y: y, width: width, height: height) } var rect: NSRect { NSRect(x: x, y: y, width: width, height: height) }
var textClip: NSRect { var textClip: NSRect { NSRect(x: overflowLeft, y: y, width: overflowRight - overflowLeft, height: height) }
NSRect(x: overflowLeft, y: y, width: overflowRight - overflowLeft, height: height)
}
} }
struct SpreadsheetBorder: Decodable { struct SpreadsheetBorder: Decodable {
let style: String let style, color: String
let color: String
var width: CGFloat { var width: CGFloat {
switch style { switch style {
case "hair": 0.5 case "hair": 0.5
@@ -63,31 +59,24 @@ struct SpreadsheetBorder: Decodable {
struct SpreadsheetStyle: Decodable { struct SpreadsheetStyle: Decodable {
let font: String let font: String
let fontSize: Double let fontSize: Double
let bold: Bool let bold, italic, underline, strike: Bool
let italic: Bool let color, fill, horizontal, vertical: String
let underline: Bool let wrap, shrink: 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 rotation: Int
let indent: Double let indent: Double
let borders: [String: SpreadsheetBorder] let borders: [String: SpreadsheetBorder]
let accounting: Bool
@MainActor func resolvedFont(size: CGFloat? = nil) -> NSFont { @MainActor func resolvedFont(size: CGFloat? = nil) -> NSFont {
let pointSize: CGFloat = size ?? CGFloat(fontSize * 4 / 3) let pointSize: CGFloat = size ?? CGFloat(fontSize * 4 / 3)
let lower = font.lowercased() let lower = font.lowercased()
let fallback: String let fallback: String
if lower.contains("simsun") || font.contains("") { // SimSun-ExtB is an extension font, not the installed Chinese Songti
fallback = "Songti SC" // face. WPS uses a sans fallback for its unsupported base characters.
} else if lower.contains("kai") || font.contains("") { if lower.contains("extb") { fallback = "PingFang SC" }
fallback = "Kaiti SC" else if lower.contains("simsun") || font.contains("") { fallback = "Songti SC" }
} else { else if lower.contains("kai") || font.contains("") { fallback = "Kaiti SC" }
fallback = "PingFang SC" else { fallback = "Arial" }
}
var result = NSFont(name: font, size: pointSize) ?? NSFont(name: fallback, size: pointSize) var result = NSFont(name: font, size: pointSize) ?? NSFont(name: fallback, size: pointSize)
?? NSFont.systemFont(ofSize: pointSize) ?? NSFont.systemFont(ofSize: pointSize)
if bold { result = NSFontManager.shared.convert(result, toHaveTrait: .boldFontMask) } if bold { result = NSFontManager.shared.convert(result, toHaveTrait: .boldFontMask) }
@@ -97,10 +86,7 @@ struct SpreadsheetStyle: Decodable {
} }
enum SpreadsheetZoom: String, CaseIterable { enum SpreadsheetZoom: String, CaseIterable {
case width = "适合宽度" case width = "适合宽度", page = "整页", actual = "100%"
case page = "整页"
case actual = "100%"
func scale(sheet: SpreadsheetSheet, viewport: CGSize) -> CGFloat { func scale(sheet: SpreadsheetSheet, viewport: CGSize) -> CGFloat {
let width = max(1, viewport.width - 48) / sheet.width let width = max(1, viewport.width - 48) / sheet.width
let height = max(1, viewport.height - 48) / sheet.height let height = max(1, viewport.height - 48) / sheet.height
@@ -114,6 +100,8 @@ enum SpreadsheetZoom: String, CaseIterable {
struct ExpenseSpreadsheetPreview: View { struct ExpenseSpreadsheetPreview: View {
let layout: SpreadsheetLayout let layout: SpreadsheetLayout
var preferredSheet: String?
var onExpand: (() -> Void)?
@State private var sheetIndex = 0 @State private var sheetIndex = 0
@State private var zoom: SpreadsheetZoom = .width @State private var zoom: SpreadsheetZoom = .width
@State private var customScale: CGFloat? @State private var customScale: CGFloat?
@@ -121,49 +109,47 @@ struct ExpenseSpreadsheetPreview: View {
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let sheet = layout.sheets[min(sheetIndex, layout.sheets.count - 1)] 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) let scale = customScale ?? zoom.scale(sheet: sheet, viewport: viewport)
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 12) { HStack(spacing: 10) {
Picker("工作表", selection: $sheetIndex) { Picker("工作表", selection: $sheetIndex) {
ForEach(layout.sheets.indices, id: \.self) { index in ForEach(layout.sheets.indices, id: \.self) { Text(layout.sheets[$0].name).tag($0) }
Text(layout.sheets[index].name).tag(index) }.labelsHidden().frame(minWidth: 90, maxWidth: 200)
} Spacer(minLength: 0)
}.frame(maxWidth: 260)
Spacer(minLength: 8)
Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: { Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: {
if let mode = $0 { zoom = mode; customScale = nil } if let mode = $0 { zoom = mode; customScale = nil }
})) { })) {
ForEach(SpreadsheetZoom.allCases, id: \.self) { mode in ForEach(SpreadsheetZoom.allCases, id: \.self) { Text($0.rawValue).tag(Optional($0)) }
Text(mode.rawValue).tag(Optional(mode)) }.pickerStyle(.segmented).labelsHidden().frame(width: 190)
} Button { customScale = max(0.1, scale - 0.1) } label: {
}.pickerStyle(.segmented).frame(width: 220) Image(systemName: "minus.magnifyingglass")
Button { }.help("缩小").disabled(scale <= 0.1)
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)))) Text(Double(scale).formatted(.percent.precision(.fractionLength(0))))
.font(.callout.monospacedDigit()).frame(width: 48) .font(.callout.monospacedDigit()).frame(width: 45)
Button { Button { customScale = min(2.5, scale + 0.1) } label: {
customScale = min(2.5, scale + 0.1) Image(systemName: "plus.magnifyingglass")
} label: { Image(systemName: "plus.magnifyingglass") } }.help("放大").disabled(scale >= 2.5)
.help("放大").disabled(scale >= 2.5) if let onExpand {
}.padding(.horizontal, 20).frame(height: 48) Button(action: onExpand) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
}.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口")
}
}.padding(.horizontal, 16).frame(height: 44)
.background(Color(nsColor: .windowBackgroundColor)) .background(Color(nsColor: .windowBackgroundColor))
Divider()
ScrollView([.horizontal, .vertical]) { ScrollView([.horizontal, .vertical]) {
SpreadsheetCanvas(sheet: sheet, scale: scale) SpreadsheetCanvas(sheet: sheet, scale: scale)
.frame(width: sheet.width * scale, height: sheet.height * scale) .frame(width: sheet.width * scale, height: sheet.height * scale)
.background(.white) .background(.white).padding(24)
.overlay(Rectangle().stroke(Color.black.opacity(0.12), lineWidth: 1))
.padding(24)
.frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top) .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 { struct SpreadsheetCanvas: NSViewRepresentable {
let sheet: SpreadsheetSheet let sheet: SpreadsheetSheet
let scale: CGFloat 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) { func updateNSView(_ view: SpreadsheetCanvasView, context: Context) {
view.sheet = sheet view.sheet = sheet
view.scale = scale view.scale = scale
@@ -184,11 +166,17 @@ struct SpreadsheetCanvas: NSViewRepresentable {
} }
@MainActor final class SpreadsheetCanvasView: NSView { @MainActor final class SpreadsheetCanvasView: NSView {
struct TextPlacement {
let cell: SpreadsheetCell
let text: NSAttributedString
let box: NSRect
let overflow: NSRect?
}
var sheet: SpreadsheetSheet var sheet: SpreadsheetSheet
var scale: CGFloat var scale: CGFloat
override var isFlipped: Bool { true } override var isFlipped: Bool { true }
override var isOpaque: Bool { true } override var isOpaque: Bool { true }
init(sheet: SpreadsheetSheet, scale: CGFloat) { init(sheet: SpreadsheetSheet, scale: CGFloat) {
self.sheet = sheet self.sheet = sheet
self.scale = scale self.scale = scale
@@ -196,9 +184,91 @@ struct SpreadsheetCanvas: NSViewRepresentable {
setAccessibilityLabel(sheet.name) setAccessibilityLabel(sheet.name)
setAccessibilityRole(.image) setAccessibilityRole(.image)
} }
required init?(coder: NSCoder) { nil } 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<CGFloat>] {
var ranges = [top...bottom]
for rect in overflow where x > rect.minX && x < rect.maxX {
ranges = ranges.flatMap { range -> [ClosedRange<CGFloat>] in
guard rect.maxY > range.lowerBound && rect.minY < range.upperBound else { return [range] }
var remaining: [ClosedRange<CGFloat>] = []
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) { override func draw(_ dirtyRect: NSRect) {
NSColor.white.setFill() NSColor.white.setFill()
dirtyRect.fill() dirtyRect.fill()
@@ -209,104 +279,56 @@ struct SpreadsheetCanvas: NSViewRepresentable {
let visible = NSRect(x: dirtyRect.minX / scale, y: dirtyRect.minY / 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) width: dirtyRect.width / scale, height: dirtyRect.height / scale).insetBy(dx: -3, dy: -3)
let cells = sheet.cells.filter { $0.textClip.intersects(visible) } let cells = sheet.cells.filter { $0.textClip.intersects(visible) }
// Fills, text and borders are separate passes so adjacent fills never let placements = cells.filter { !$0.text.isEmpty }.map { placement(for: $0) }
// erase shared cell borders. The entire sheet uses one uniform scale. let overflow = placements.compactMap(\.overflow)
for cell in cells { for cell in cells {
Self.color(cell.style.fill).setFill() Self.color(cell.style.fill).setFill()
cell.rect.fill() cell.rect.fill()
} }
for cell in cells where !cell.text.isEmpty { drawText(cell, context: context) } // Strong shared edges win over thin edges regardless of XML cell order.
for cell in cells { drawBorders(cell, context: context) } 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 {
static func color(_ hex: String) -> NSColor { drawBorder(cell, edge: edge, border: border, overflow: overflow, context: context)
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() for placement in placements {
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.saveGState()
context.setStrokeColor(Self.color(border.color).cgColor) context.clip(to: placement.cell.textClip)
context.setLineWidth(border.style == "double" ? 1 : border.width) if (1...180).contains(placement.cell.style.rotation) {
if border.style.lowercased().contains("dash") { context.setLineDash(phase: 0, lengths: [5, 3]) } let rotation = placement.cell.style.rotation
if border.style == "dotted" { context.setLineDash(phase: 0, lengths: [1, 2]) } let angle = rotation <= 90 ? rotation : 90 - rotation
if border.style == "double" { context.translateBy(x: placement.cell.rect.midX, y: placement.cell.rect.midY)
for offset in [-1.0, 1.0] { context.rotate(by: -CGFloat(angle) * .pi / 180)
let dx = edge == "left" || edge == "right" ? offset : 0 placement.text.draw(at: NSPoint(x: -placement.box.width / 2, y: -placement.box.height / 2))
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 { } else {
context.move(to: start) placement.text.draw(with: placement.box, options: [.usesLineFragmentOrigin, .usesFontLeading])
context.addLine(to: end)
} }
context.strokePath()
context.restoreGState() 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()
}
} }
+143
View File
@@ -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<ExpenseSpreadsheetPreview>?
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
}
}
+2 -4
View File
@@ -447,12 +447,10 @@ final class WorkspaceStore: ObservableObject {
try ExpenseSummary.validateExportResult(result, matches: snapshot.matches) try ExpenseSummary.validateExportResult(result, matches: snapshot.matches)
if let template = snapshot.template { try ExpenseTemplateStorage.validateExport(result, template: template) } if let template = snapshot.template { try ExpenseTemplateStorage.validateExport(result, template: template) }
let metadata = try JSONSerialization.jsonObject(with: result) as? [String: Any] let metadata = try JSONSerialization.jsonObject(with: result) as? [String: Any]
guard metadata?["expensePreview"] as? String == "xlsx-layout-v2", guard metadata?["expensePreview"] as? String == "xlsx-layout-v4" else {
let layoutObject = metadata?["previewLayout"] else {
throw ExpenseTemplateFailure.message("当前引擎不支持报销单预览,请重新构建应用。") throw ExpenseTemplateFailure.message("当前引擎不支持报销单预览,请重新构建应用。")
} }
let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: let layout = try SpreadsheetLayout.decode(metadata?["previewLayout"])
JSONSerialization.data(withJSONObject: layoutObject)).validated()
expensePreview = PreparedExpensePreview( expensePreview = PreparedExpensePreview(
file: file, requestDigest: digest, file: file, requestDigest: digest,
fileDigest: ExpenseTemplateStorage.fingerprint(try Data(contentsOf: file)), fileDigest: ExpenseTemplateStorage.fingerprint(try Data(contentsOf: file)),
+109 -41
View File
@@ -4,51 +4,119 @@ import SwiftUI
@main @main
struct SpreadsheetLayoutTests { struct SpreadsheetLayoutTests {
@MainActor static func main() throws { @MainActor static func main() throws {
let source = URL(fileURLWithPath: CommandLine.arguments[1]) _ = NSApplication.shared
let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: Data(contentsOf: source)).validated() 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 sheet = layout.sheets[0]
let narrow = SpreadsheetZoom.width.scale(sheet: sheet, viewport: CGSize(width: 800, height: 500)) let cells = Dictionary(uniqueKeysWithValues: sheet.cells.map { ($0.reference, $0) })
let wide = SpreadsheetZoom.width.scale(sheet: sheet, viewport: CGSize(width: 1800, height: 900)) let canvas = SpreadsheetCanvasView(sheet: sheet, scale: 1)
precondition(narrow * sheet.width <= 752) let placements = sheet.cells.filter { !$0.text.isEmpty }.map { canvas.placement(for: $0) }
precondition(wide == 1) let overflow = placements.compactMap(\.overflow)
let page = SpreadsheetZoom.page.scale(sheet: sheet, viewport: CGSize(width: 800, height: 500)) for ref in ["A30", "D30", "A31"] {
precondition(page * sheet.height <= 452) let cell = cells[ref]!
precondition(SpreadsheetZoom.actual.scale(sheet: sheet, viewport: .zero) == 1) precondition(canvas.verticalSegments(x: cell.rect.maxX, top: cell.rect.minY,
precondition(sheet.cells.first { $0.reference == "H30" }?.style.rotation == 255) bottom: cell.rect.maxY, overflow: overflow).isEmpty,
precondition(sheet.cells.first { $0.reference == "C25" }?.text == "001234567890") "A vertical line still crosses \(ref)")
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 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 { @MainActor static func containsCanvas(_ view: NSView, named: String) -> Bool {
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { if let canvas = view as? SpreadsheetCanvasView, canvas.sheet.name == named { return true }
throw ExpenseTemplateFailure.message("Cannot create preview snapshot") 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) view.cacheDisplay(in: view.bounds, to: bitmap)
guard let png = bitmap.representation(using: .png, properties: [:]) else { try bitmap.representation(using: .png, properties: [:])!.write(to: url)
throw ExpenseTemplateFailure.message("Cannot encode preview snapshot") return bitmap
}
try png.write(to: file)
} }
} }