1
This commit is contained in:
@@ -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 中。
|
||||||
|
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+107
-107
@@ -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'))
|
||||||
|
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',
|
['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4',
|
||||||
'accent5', 'accent6', 'hlink', 'folHlink']]
|
'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):
|
|
||||||
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}日'
|
return f'{date.year}年{date.month}月{date.day}日'
|
||||||
if section.lower() in ('h:mm', 'hh:mm', 'h:mm:ss', 'hh:mm:ss'):
|
if section 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')
|
return date.strftime('%H:%M:%S' if 'ss' in section else '%H:%M')
|
||||||
warnings.add('部分日期格式使用标准日期显示')
|
warnings.add('特殊日期格式请在办公软件中核对')
|
||||||
return date.date().isoformat() if isinstance(date, datetime.datetime) else date.isoformat()
|
return date.date().isoformat() if hasattr(date, 'date') 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]
|
|
||||||
optional = len(fraction) - len(fraction.rstrip('#?'))
|
|
||||||
for _ in range(optional):
|
|
||||||
if text.endswith('0'):
|
if text.endswith('0'):
|
||||||
text = text[:-1]
|
text = text[:-1]
|
||||||
|
if fraction:
|
||||||
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:
|
||||||
|
# Use the perimeter, not the anchor's former internal edges.
|
||||||
for edge, edge_ref in [('right', column_name(right) + str(row)),
|
for edge, edge_ref in [('right', column_name(right) + str(row)),
|
||||||
('bottom', column_name(col) + str(bottom)),
|
('bottom', column_name(col) + str(bottom)),
|
||||||
('right', column_name(right) + str(bottom)),
|
('right', column_name(right) + str(bottom)),
|
||||||
('bottom', column_name(right) + str(bottom))]:
|
('bottom', column_name(right) + str(bottom))]:
|
||||||
if edge not in appearance['borders'] and edge_ref in cells:
|
candidate = style_at(edge_ref)['borders'].get(edge)
|
||||||
candidate = styles[int(cells[edge_ref].get('s', 0))]['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,12 +240,19 @@ 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:
|
||||||
|
continue
|
||||||
left_limit, right_limit = col, right
|
left_limit, right_limit = col, right
|
||||||
if (text and kind in ('s', 'inlineStr', 'str') and reference not in spans
|
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):
|
and not appearance['wrap'] and not appearance['shrink'] and appearance['rotation'] == 0):
|
||||||
@@ -263,10 +263,10 @@ def read_layout(path):
|
|||||||
while left_limit > 1 and (left_limit - 1, row) not in occupied:
|
while left_limit > 1 and (left_limit - 1, row) not in occupied:
|
||||||
left_limit -= 1
|
left_limit -= 1
|
||||||
output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height,
|
output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height,
|
||||||
text=text, style=appearance,
|
text=text, style=appearance, overflowLeft=xs[left_limit - 1],
|
||||||
overflowLeft=xs[left_limit - 1], overflowRight=xs[right_limit]))
|
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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+186
-164
@@ -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 {
|
|
||||||
throw ExpenseTemplateFailure.message("报销单排版数据不完整,请重新生成预览。")
|
|
||||||
}
|
}
|
||||||
|
}) else { 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)))
|
||||||
.background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1)))
|
|
||||||
.id(sheetIndex)
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
||||||
|
drawBorder(cell, edge: edge, border: border, overflow: overflow, context: context)
|
||||||
|
}
|
||||||
|
for placement in placements {
|
||||||
|
context.saveGState()
|
||||||
|
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 {
|
||||||
|
placement.text.draw(with: placement.box, options: [.usesLineFragmentOrigin, .usesFontLeading])
|
||||||
|
}
|
||||||
|
context.restoreGState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func color(_ hex: String) -> NSColor {
|
private func drawBorder(_ cell: SpreadsheetCell, edge: String, border: SpreadsheetBorder,
|
||||||
let value = UInt32(hex, radix: 16) ?? 0
|
overflow: [NSRect], context: CGContext) {
|
||||||
return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255,
|
let rect = cell.rect
|
||||||
green: CGFloat((value >> 8) & 255) / 255,
|
let vertical = edge == "left" || edge == "right"
|
||||||
blue: CGFloat(value & 255) / 255, alpha: 1)
|
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) :
|
||||||
private func drawText(_ cell: SpreadsheetCell, context: CGContext) {
|
[rect.minX...rect.maxX]
|
||||||
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()
|
context.saveGState()
|
||||||
defer { context.restoreGState() }
|
defer { context.restoreGState() }
|
||||||
context.clip(to: cell.textClip)
|
|
||||||
if cell.textClip.width > cell.width {
|
|
||||||
let natural = ceil((text as NSString).size(withAttributes: attributes).width)
|
|
||||||
if natural > box.width {
|
|
||||||
if paragraph.alignment == .right { box.origin.x -= natural - box.width }
|
|
||||||
else if paragraph.alignment == .center { box.origin.x -= (natural - box.width) / 2 }
|
|
||||||
box.size.width = natural
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (1...180).contains(style.rotation) {
|
|
||||||
let angle = style.rotation <= 90 ? style.rotation : 90 - style.rotation
|
|
||||||
context.translateBy(x: box.midX, y: box.midY)
|
|
||||||
context.rotate(by: -CGFloat(angle) * .pi / 180)
|
|
||||||
box = NSRect(x: -box.height / 2, y: -box.width / 2, width: box.height, height: box.width)
|
|
||||||
}
|
|
||||||
let attributed = NSAttributedString(string: text, attributes: attributes)
|
|
||||||
let options: NSString.DrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
|
|
||||||
let measured = attributed.boundingRect(with: NSSize(width: box.width, height: 100000), options: options)
|
|
||||||
let textHeight = min(box.height, ceil(measured.height))
|
|
||||||
if style.vertical == "center" { box.origin.y += (box.height - textHeight) / 2 }
|
|
||||||
else if style.vertical != "top" { box.origin.y += box.height - textHeight }
|
|
||||||
box.size.height = textHeight
|
|
||||||
attributed.draw(with: box, options: options)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drawBorders(_ cell: SpreadsheetCell, context: CGContext) {
|
|
||||||
let rect = cell.rect
|
|
||||||
for (edge, border) in cell.style.borders {
|
|
||||||
let start: CGPoint
|
|
||||||
let end: CGPoint
|
|
||||||
switch edge {
|
|
||||||
case "top": start = CGPoint(x: rect.minX, y: rect.minY); end = CGPoint(x: rect.maxX, y: rect.minY)
|
|
||||||
case "bottom": start = CGPoint(x: rect.minX, y: rect.maxY); end = CGPoint(x: rect.maxX, y: rect.maxY)
|
|
||||||
case "left": start = CGPoint(x: rect.minX, y: rect.minY); end = CGPoint(x: rect.minX, y: rect.maxY)
|
|
||||||
default: start = CGPoint(x: rect.maxX, y: rect.minY); end = CGPoint(x: rect.maxX, y: rect.maxY)
|
|
||||||
}
|
|
||||||
context.saveGState()
|
|
||||||
context.setStrokeColor(Self.color(border.color).cgColor)
|
context.setStrokeColor(Self.color(border.color).cgColor)
|
||||||
context.setLineWidth(border.style == "double" ? 1 : border.width)
|
context.setLineWidth(border.style == "double" ? 1 : border.width)
|
||||||
if border.style.lowercased().contains("dash") { context.setLineDash(phase: 0, lengths: [5, 3]) }
|
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 == "dotted" { context.setLineDash(phase: 0, lengths: [1, 2]) }
|
||||||
if border.style == "double" {
|
for range in ranges {
|
||||||
for offset in [-1.0, 1.0] {
|
for offset in border.style == "double" ? [-1.0, 1.0] : [0.0] {
|
||||||
let dx = edge == "left" || edge == "right" ? offset : 0
|
context.move(to: vertical ? CGPoint(x: fixed + offset, y: range.lowerBound) :
|
||||||
let dy = dx == 0 ? offset : 0
|
CGPoint(x: range.lowerBound, y: fixed + offset))
|
||||||
context.move(to: CGPoint(x: start.x + dx, y: start.y + dy))
|
context.addLine(to: vertical ? CGPoint(x: fixed + offset, y: range.upperBound) :
|
||||||
context.addLine(to: CGPoint(x: end.x + dx, y: end.y + dy))
|
CGPoint(x: range.upperBound, y: fixed + offset))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
context.move(to: start)
|
|
||||||
context.addLine(to: end)
|
|
||||||
}
|
}
|
||||||
context.strokePath()
|
context.strokePath()
|
||||||
context.restoreGState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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])
|
|
||||||
let layout = try JSONDecoder().decode(SpreadsheetLayout.self, from: Data(contentsOf: source)).validated()
|
|
||||||
let sheet = layout.sheets[0]
|
|
||||||
let narrow = SpreadsheetZoom.width.scale(sheet: sheet, viewport: CGSize(width: 800, height: 500))
|
|
||||||
let wide = SpreadsheetZoom.width.scale(sheet: sheet, viewport: CGSize(width: 1800, height: 900))
|
|
||||||
precondition(narrow * sheet.width <= 752)
|
|
||||||
precondition(wide == 1)
|
|
||||||
let page = SpreadsheetZoom.page.scale(sheet: sheet, viewport: CGSize(width: 800, height: 500))
|
|
||||||
precondition(page * sheet.height <= 452)
|
|
||||||
precondition(SpreadsheetZoom.actual.scale(sheet: sheet, viewport: .zero) == 1)
|
|
||||||
precondition(sheet.cells.first { $0.reference == "H30" }?.style.rotation == 255)
|
|
||||||
precondition(sheet.cells.first { $0.reference == "C25" }?.text == "001234567890")
|
|
||||||
precondition(sheet.cells.first { $0.reference == "F27" }?.text.trimmingCharacters(in: .whitespaces) == "544.30")
|
|
||||||
let signature = sheet.cells.first { $0.reference == "A30" }!
|
|
||||||
precondition(signature.textClip.width > signature.width)
|
|
||||||
print("Workbook layout, zoom bounds, vertical text and exact values passed")
|
|
||||||
if CommandLine.arguments.count > 2 {
|
|
||||||
let folder = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
NSApplication.shared.appearance = NSAppearance(named: .aqua)
|
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 cells = Dictionary(uniqueKeysWithValues: sheet.cells.map { ($0.reference, $0) })
|
||||||
let canvas = SpreadsheetCanvasView(sheet: sheet, scale: 1)
|
let canvas = SpreadsheetCanvasView(sheet: sheet, scale: 1)
|
||||||
try capture(canvas, to: folder.appendingPathComponent("sheet.png"))
|
let placements = sheet.cells.filter { !$0.text.isEmpty }.map { canvas.placement(for: $0) }
|
||||||
for size in [CGSize(width: 1080, height: 620), CGSize(width: 820, height: 480)] {
|
let overflow = placements.compactMap(\.overflow)
|
||||||
let host = NSHostingView(rootView: ExpenseSpreadsheetPreview(layout: layout))
|
for ref in ["A30", "D30", "A31"] {
|
||||||
host.frame = NSRect(origin: .zero, size: size)
|
let cell = cells[ref]!
|
||||||
let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false)
|
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
|
window.contentView = host
|
||||||
host.layoutSubtreeIfNeeded()
|
host.layoutSubtreeIfNeeded()
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
try capture(host, to: folder.appendingPathComponent("preview-\(Int(size.width)).png"))
|
_ = try capture(host, to: folder.appendingPathComponent("preview-\(width).png"))
|
||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
}
|
}
|
||||||
print("Native preview snapshots written to \(folder.path)")
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user