This commit is contained in:
csj
2026-09-17 14:13:31 +08:00
parent ced5db5473
commit 10697d3dfa
18 changed files with 2097 additions and 26 deletions
+220
View File
@@ -0,0 +1,220 @@
import ast
import datetime
import re
import zipfile
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from expense_template import Template, column_name, coordinate, encoded, tag
class UnsupportedFormula(ValueError):
pass
RMB_FORMULA = ('SUBSTITUTE(SUBSTITUTE(IF({cell}>-0.5%,,"")&TEXT(INT(ABS({cell})+0.5%),'
'"[dbnum2]G/通用格式元;;")&TEXT(RIGHT(FIXED({cell}),2),"[dbnum2]0角0分;;"&'
'IF(ABS({cell})>1%,"",)),"零角",IF(ABS({cell})<1,,"")),"零分","")')
def chinese_integer(number):
digits = '零壹贰叁肆伍陆柒捌玖'
if not 0 <= number < 10 ** 12:
raise UnsupportedFormula('大写金额超出预览范围')
if number == 0:
return ''
result, pending_zero = '', False
for divisor, suffix in [(10 ** 8, '亿'), (10 ** 4, ''), (1, '')]:
group, number = divmod(number, divisor)
if not group:
if result:
pending_zero = True
continue
if result and (pending_zero or group < 1000):
result += ''
fragment, zero = '', False
for unit, label in [(1000, ''), (100, ''), (10, ''), (1, '')]:
digit, group = divmod(group, unit)
if digit:
if zero:
fragment += ''
fragment += digits[digit] + label
zero = False
elif fragment:
zero = True
result += fragment + suffix
pending_zero = False
return result
def builtin_rmb(value):
absolute = abs(value)
integer = int(absolute + Decimal('0.005'))
cents = int(absolute.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) * 100) % 100
result = '' if value > Decimal('-0.005') else ''
if integer:
result += chinese_integer(integer) + ''
digits = '零壹贰叁肆伍陆柒捌玖'
result += (digits[cents // 10] + '' + digits[cents % 10] + '') if cents else ('' if absolute > Decimal('0.01') else '')
return result.replace('零角', '' if absolute < 1 else '').replace('零分', '')
class PreviewCalculator:
def __init__(self, document, date_1904=False):
self.cells = {cell.get('r'): cell for cell in document.iter(tag('c'))}
self.values = {}
self.visiting = set()
self.epoch = datetime.date(1904, 1, 1) if date_1904 else datetime.date(1899, 12, 30)
def cell(self, reference):
if reference in self.values:
return self.values[reference]
if reference in self.visiting or len(self.visiting) >= 100:
raise UnsupportedFormula('循环引用或嵌套过深')
cell = self.cells.get(reference)
if cell is None:
return Decimal(0)
self.visiting.add(reference)
try:
formula = cell.find(tag('f'))
if formula is not None:
if formula.get('t') not in (None, 'normal'):
raise UnsupportedFormula('数组或共享公式')
result = self.expression(formula.text or '')
elif cell.get('t') in (None, 'n'):
result = Decimal(cell.findtext(tag('v')) or '0')
elif cell.get('t') == 'inlineStr' and not ''.join(cell.itertext()).strip():
result = Decimal(0)
else:
raise UnsupportedFormula('非数值单元格')
if isinstance(result, Decimal) and not result.is_finite():
raise UnsupportedFormula('非有限数值')
self.values[reference] = result
return result
finally:
self.visiting.remove(reference)
def expression(self, formula):
formula = formula.strip().replace('$', '')
if len(formula) > 2000:
raise UnsupportedFormula('公式过长')
if formula.startswith('SUBSTITUTE(SUBSTITUTE(IF('):
matched = re.match(r'SUBSTITUTE\(SUBSTITUTE\(IF\(([A-Z]+[0-9]+)>', formula)
if matched and formula == RMB_FORMULA.format(cell=matched[1]):
amount = self.cell(matched[1])
if not isinstance(amount, Decimal):
raise UnsupportedFormula('大写金额来源不是数值')
return builtin_rmb(amount)
if formula.upper() == 'TODAY()':
return Decimal((datetime.date.today() - self.epoch).days)
matched = re.fullmatch(r'SUM\(([A-Z]+[0-9]+):([A-Z]+[0-9]+)\)', formula, re.I)
if matched:
left, top = coordinate(matched[1].upper())
right, bottom = coordinate(matched[2].upper())
if right < left or bottom < top or (right - left + 1) * (bottom - top + 1) > 20000:
raise UnsupportedFormula('求和范围过大')
total = Decimal(0)
for row in range(top, bottom + 1):
for column in range(left, right + 1):
reference = column_name(column) + str(row)
cell = self.cells.get(reference)
if cell is not None and cell.find(tag('f')) is None and cell.get('t') not in (None, 'n'):
continue
value = self.cell(reference)
if not isinstance(value, str):
total += value
return total
if formula.upper().startswith('IF(') and formula.endswith(')'):
arguments = []
depth, start = 0, 3
for index in range(3, len(formula) - 1):
character = formula[index]
if character == '(':
depth += 1
elif character == ')':
depth -= 1
elif character == ',' and depth == 0:
arguments.append(formula[start:index])
start = index + 1
arguments.append(formula[start:-1])
if len(arguments) != 3:
raise UnsupportedFormula('不支持的条件公式')
return self.expression(arguments[1] if self.expression(arguments[0]) else arguments[2])
formula = formula.replace('<>', '!=')
formula = re.sub(r'(?<![<>=!])=(?!=)', '==', formula)
tree = ast.parse(formula, mode='eval')
return self.node(tree.body)
def node(self, node):
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
return Decimal(str(node.value))
if isinstance(node, ast.Name) and re.fullmatch(r'[A-Z]{1,3}[1-9][0-9]{0,3}', node.id, re.I):
return self.cell(node.id.upper())
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
value = self.node(node.operand)
if isinstance(value, str):
raise UnsupportedFormula('非数值运算')
return -value if isinstance(node.op, ast.USub) else value
if isinstance(node, ast.BinOp):
left, right = self.node(node.left), self.node(node.right)
if isinstance(left, str) or isinstance(right, str):
raise UnsupportedFormula('非数值运算')
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if isinstance(node.op, ast.Div):
return left / right
if isinstance(node, ast.Compare) and len(node.ops) == 1:
left, right = self.node(node.left), self.node(node.comparators[0])
if isinstance(left, str) or isinstance(right, str):
raise UnsupportedFormula('非数值比较')
operator = node.ops[0]
if isinstance(operator, ast.Gt):
return left > right
if isinstance(operator, ast.GtE):
return left >= right
if isinstance(operator, ast.Lt):
return left < right
if isinstance(operator, ast.LtE):
return left <= right
if isinstance(operator, ast.Eq):
return left == right
if isinstance(operator, ast.NotEq):
return left != right
raise UnsupportedFormula('自定义公式需由 Excel / WPS 计算')
def prepare_preview(path):
from lxml import etree as ET
template = Template(path)
properties = template.workbook.find(tag('workbookPr'))
date_1904 = properties is not None and properties.get('date1904') in ('1', 'true')
uncalculated = []
for sheet in template.sheets:
if sheet.get('state', 'visible') != 'visible':
continue
_, sheet_path, document = template.sheet(sheet.get('name'))
calculator = PreviewCalculator(document, date_1904)
for cell in document.iter(tag('c')):
if cell.find(tag('f')) is None:
continue
for cached in cell.findall(tag('v')):
cell.remove(cached)
try:
value = calculator.cell(cell.get('r'))
cell.set('t', 'str' if isinstance(value, str) else 'b' if isinstance(value, bool) else 'n')
ET.SubElement(cell, tag('v')).text = str(int(value)) if isinstance(value, bool) else str(value)
except (ValueError, SyntaxError, ArithmeticError, InvalidOperation, RecursionError):
uncalculated.append(sheet.get('name') + '!' + cell.get('r'))
template.parts[sheet_path] = encoded(document)
for name, document in [('xl/workbook.xml', template.workbook),
('xl/_rels/workbook.xml.rels', template.relations),
('[Content_Types].xml', template.types)]:
template.parts[name] = encoded(document)
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as output:
for name, data in template.parts.items():
output.writestr(name, data)
return dict(expensePreview='xlsx-quicklook-v1', previewUncalculatedCells=uncalculated)