223 lines
9.9 KiB
Python
223 lines
9.9 KiB
Python
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
|
|
from spreadsheet_layout import read_layout
|
|
|
|
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-layout-v2', previewUncalculatedCells=uncalculated,
|
|
previewLayout=read_layout(path))
|