"""Read-only display geometry from the exact XLSX that will be saved.""" import datetime import math import mimetypes import re from decimal import Decimal, ROUND_HALF_UP # Match the travel exporter: the app sandbox cannot read system MIME databases. mimetypes.knownfiles = [] mimetypes.init() from openpyxl.styles.colors import COLOR_INDEX from openpyxl.styles.numbers import BUILTIN_FORMATS, is_date_format from openpyxl.utils.datetime import from_excel, MAC_EPOCH, WINDOWS_EPOCH from expense_template import Template, column_name, coordinate, tag, xml DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main' MAX_CELLS = 50000 def flag(node, name, default=False): return node.get(name, '1' if default else '0') in ('1', 'true') def child_value(node, name, fallback): child = node.find(tag(name)) return child.get('val', fallback) if child is not None else fallback def theme_colors(template): if 'xl/theme/theme1.xml' not in template.parts: return ['FFFFFF', '000000', 'EEECE1', '1F497D', '4F81BD', 'C0504D', '9BBB59', '8064A2', '4BACC6', 'F79646', '0000FF', '800080'] 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')) for entry in scheme} return [by_name.get(name, '000000') for name in ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink']] def color(node, palette, fallback='000000'): if node is None: return fallback value = node.get('rgb') if value is None and 'theme' in node.attrib: value = palette[int(node.get('theme')) % len(palette)] if value is None and 'indexed' in node.attrib: index = int(node.get('indexed')) value = COLOR_INDEX[index] if index < len(COLOR_INDEX) else fallback value = (value or fallback)[-6:] if not re.fullmatch('[0-9a-fA-F]{6}', value): return fallback 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}' for channel in (int(value[index:index + 2], 16) for index in (0, 2, 4))) def literal(value): return re.sub(r'"([^"]*)"|\\(.)|_.|\*.', lambda m: m[1] or m[2] or '', value) def formatted(value, kind, code, date_1904, warnings): if value == '' or kind in ('s', 'inlineStr', 'str', 'e'): return value if kind == 'b': return 'TRUE' if value == '1' else 'FALSE' number = Decimal(value) if is_date_format(code): date = from_excel(float(number), MAC_EPOCH if date_1904 else WINDOWS_EPOCH) if isinstance(date, (datetime.datetime, datetime.time)): # Excel date/time tokens are not strftime tokens (notably month/minute). section = code.split(';')[0] section = re.sub(r'\[[^\]]*\]', '', section) section = literal(section) if isinstance(date, datetime.datetime) and section.lower() == 'mm-dd-yy': return date.strftime('%m-%d-%y') 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}日' if section.lower() in ('h:mm', 'hh:mm', 'h:mm:ss', 'hh:mm:ss'): return date.strftime('%H:%M:%S' if 'ss' in section.lower() else '%H:%M') warnings.add('部分日期格式使用标准日期显示') return date.date().isoformat() if isinstance(date, datetime.datetime) else date.isoformat() if code.lower() in ('general', '@'): return format(number, 'f').rstrip('0').rstrip('.') if '.' in str(number) else str(number) sections = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', 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) if re.search(r'\[(?:[<>=]|\d)', section): warnings.add('部分条件数字格式使用原始数值显示') return str(number) section = re.sub(r'\[[^\]]*\]', '', section) # Ignore placeholders inside quoted/escaped literals. masked = re.sub(r'"[^"]*"|\\.|_.|\*.', lambda m: ' ' * len(m[0]), section) match = re.search(r'[#0][#0,]*(?:\.[#0?]+)?%?', masked) if not match: return literal(section).strip().replace('?', '') if re.search(r'[Ee][+-]|[/]', masked) or masked[match.end():].lstrip().startswith(','): warnings.add('部分科学计数或分数格式使用原始数值显示') return str(number) pattern = match[0] places = len(pattern.rstrip('%').split('.')[1]) if '.' in pattern else 0 value = abs(number) if number < 0 and len(sections) > 1 else number if '%' in pattern: value *= 100 value = value.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP) text = format(value, (',' if ',' in pattern else '') + f'.{places}f') if '.' in pattern: fraction = pattern.rstrip('%').split('.')[1] optional = len(fraction) - len(fraction.rstrip('#?')) for _ in range(optional): if text.endswith('0'): text = text[:-1] text = text.rstrip('.') if ',' not in pattern and '.' not in pattern: text = text.zfill(pattern.count('0')) if '%' in pattern: text += '%' return literal(section[:match.start()]) + text + literal(section[match.end():]) def read_styles(template, palette): root = xml(template.parts['xl/styles.xml']) fonts, fills, borders = root.find(tag('fonts')), root.find(tag('fills')), root.find(tag('borders')) formats = dict(BUILTIN_FORMATS) for item in root.findall(tag('numFmts') + '/' + tag('numFmt')): formats[int(item.get('numFmtId'))] = item.get('formatCode') styles = [] for entry in root.find(tag('cellXfs')): font = fonts[int(entry.get('fontId', 0))] fill = fills[int(entry.get('fillId', 0))].find(tag('patternFill')) border = borders[int(entry.get('borderId', 0))] alignment = entry.find(tag('alignment')) if alignment is None: alignment = {} sides = {} for edge in ('left', 'right', 'top', 'bottom'): side = border.find(tag(edge)) if side is not None and side.get('style'): sides[edge] = dict(style=side.get('style'), color=color(side.find(tag('color')), palette)) styles.append(dict( font=child_value(font, 'name', 'Calibri'), fontSize=float(child_value(font, 'sz', '11')), bold=font.find(tag('b')) is not None and flag(font.find(tag('b')), 'val', True), italic=font.find(tag('i')) is not None and flag(font.find(tag('i')), 'val', True), underline=font.find(tag('u')) is not None and child_value(font, 'u', 'single') != 'none', strike=font.find(tag('strike')) is not None and flag(font.find(tag('strike')), 'val', True), color=color(font.find(tag('color')), palette), fill=color(fill.find(tag('fgColor')), palette, 'FFFFFF') if fill is not None and fill.get('patternType') == 'solid' else 'FFFFFF', horizontal=alignment.get('horizontal', 'general'), vertical=alignment.get('vertical', 'bottom'), 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)), borders=sides, numberFormat=formats.get(int(entry.get('numFmtId', 0)), 'General') )) return styles def read_layout(path): template = Template(path) palette = theme_colors(template) styles = read_styles(template, palette) properties = template.workbook.find(tag('workbookPr')) date_1904 = properties is not None and flag(properties, 'date1904') warnings = set() sheets, total_cells = [], 0 for sheet_index, sheet in enumerate(template.sheets): if sheet.get('state', 'visible') != 'visible': continue name = sheet.get('name') _, _, document = template.sheet(name) cells = {cell.get('r'): cell for cell in document.iter(tag('c'))} merges = [item.get('ref') for item in document.iter(tag('mergeCell'))] # Empty formatted tail columns must not squeeze the actual form. Preserve # 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')): 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 += [f'{c}{r}' for a, b, c, r in ranges] references += [entry.split(':')[-1] for entry in merges] if not references: 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) if columns * rows > MAX_CELLS or total_cells + len(cells) > MAX_CELLS: raise ValueError('模板范围过大,无法完整预览;请精简模板后重试') total_cells += len(cells) defaults = document.find(tag('sheetFormatPr')) default_width = 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 widths = [default_width] * columns heights = [default_height] * rows for column in document.findall(tag('cols') + '/' + tag('col')): for index in range(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)) for row in document.findall(tag('sheetData') + '/' + tag('row')): index = int(row.get('r')) - 1 if index < rows: heights[index] = 0 if flag(row, 'hidden') else float(row.get('ht', default_height)) # OOXML width is expressed in maximum-digit units. Geometry is kept at # 96 dpi; Swift scales the complete sheet, never individual columns. widths = [math.floor(((256 * width + 18) / 256) * 7) for width in widths] heights = [height * 4 / 3 for height in heights] xs, ys = [0], [0] for width in widths: xs.append(xs[-1] + width) for height in heights: ys.append(ys[-1] + height) covered, spans = set(), {} for merged in merges: first, last = merged.split(':') left, top = coordinate(first) right, bottom = coordinate(last) spans[first] = (right, bottom) covered.update((col, row) for col in range(left, right + 1) for row in range(top, bottom + 1) if (col, row) != (left, top)) output = [] occupied = {coordinate(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} occupied.update(covered) occupied.update(coordinate(ref) for ref in spans) for reference, cell in cells.items(): col, row = coordinate(reference) if col > columns or row > rows or (col, row) in covered: continue right, bottom = spans.get(reference, (col, row)) style = int(cell.get('s', 0)) appearance = dict(styles[style]) appearance['borders'] = dict(appearance['borders']) # Merged right/bottom edges may be stored on the last cell. for edge, edge_ref in [('right', column_name(right) + str(row)), ('bottom', column_name(col) + str(bottom)), ('right', column_name(right) + str(bottom)), ('bottom', column_name(right) + str(bottom))]: if edge not in appearance['borders'] and edge_ref in cells: candidate = styles[int(cells[edge_ref].get('s', 0))]['borders'].get(edge) if candidate: appearance['borders'][edge] = candidate kind = cell.get('t', 'n') value = cell.findtext(tag('v'), '') if kind == 's': value = template.shared[int(value)] if value else '' elif kind == 'inlineStr': value = ''.join(node.text or '' for node in cell.iter(tag('t'))) text = formatted(value, kind, appearance['numberFormat'], date_1904, warnings) if appearance['horizontal'] == 'general': 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] if width > 0 and height > 0: left_limit, right_limit = col, right 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): if appearance['horizontal'] in ('left', 'center'): while right_limit < columns and (right_limit + 1, row) not in occupied: right_limit += 1 if appearance['horizontal'] in ('right', 'center'): while left_limit > 1 and (left_limit - 1, row) not in occupied: left_limit -= 1 output.append(dict(reference=reference, x=xs[col - 1], y=ys[row - 1], width=width, height=height, text=text, style=appearance, overflowLeft=xs[left_limit - 1], overflowRight=xs[right_limit])) for feature in ('drawing', 'legacyDrawing', 'conditionalFormatting'): if document.find(tag(feature)) is not None: warnings.add('模板中的图片、图表或条件格式请在 Excel / WPS 中核对') 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))