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)