| 1 | """Independent formatting oracle from the application's XML and inline HTML.""" |
| 2 | from html.parser import HTMLParser |
| 3 | from native_xml import ns, project_text |
| 4 | from PIL import ImageColor |
| 5 | |
| 6 | |
| 7 | def css(value): |
| 8 | result = {} |
| 9 | for item in value.split(';'): |
| 10 | key, sep, text = item.partition(':') |
| 11 | if not sep: |
| 12 | continue |
| 13 | key, text = key.strip().lower(), text.strip().strip('"\'') |
| 14 | if key == 'font-family': result['font'] = text |
| 15 | elif key == 'font-size': result['font_size'] = float(text.removesuffix('pt')) |
| 16 | elif key == 'font-weight': result['bold'] = text in ('bold', '700') |
| 17 | elif key == 'font-style': result['italic'] = text == 'italic' |
| 18 | elif key == 'text-decoration': |
| 19 | result['underline'] = 'underline' in text |
| 20 | result['strike'] = 'line-through' in text |
| 21 | elif key == 'vertical-align': |
| 22 | result['superscript'] = text == 'super' |
| 23 | result['subscript'] = text == 'sub' |
| 24 | elif key == 'color': result['color'] = text.lower() |
| 25 | elif key == 'background': result['highlight'] = text.lower() |
| 26 | return result |
| 27 | |
| 28 | |
| 29 | class Runs(HTMLParser): |
| 30 | def __init__(self, html, inherited): |
| 31 | super().__init__() |
| 32 | self.stack = [inherited] |
| 33 | self.characters = [] |
| 34 | self.after_br = False |
| 35 | self.feed(html) |
| 36 | |
| 37 | def handle_starttag(self, tag, attrs): |
| 38 | attrs = dict(attrs) |
| 39 | style = {**self.stack[-1], **css(attrs.get('style', ''))} |
| 40 | if tag in ('b', 'strong'): style['bold'] = True |
| 41 | if tag in ('i', 'em'): style['italic'] = True |
| 42 | if tag == 'u': style['underline'] = True |
| 43 | if tag in ('s', 'strike'): style['strike'] = True |
| 44 | if tag == 'sup': style['superscript'] = True |
| 45 | if tag == 'sub': style['subscript'] = True |
| 46 | if tag == 'a': |
| 47 | style['hyperlink'] = True |
| 48 | style['link'] = attrs.get('href') |
| 49 | if tag == 'br': |
| 50 | self.characters.append(('\n', self.stack[-1])) |
| 51 | self.after_br = True |
| 52 | else: |
| 53 | self.stack.append(style) |
| 54 | |
| 55 | def handle_endtag(self, tag): |
| 56 | if len(self.stack) > 1: |
| 57 | self.stack.pop() |
| 58 | |
| 59 | def handle_data(self, data): |
| 60 | if self.after_br: |
| 61 | data = data.removeprefix('\n') |
| 62 | self.after_br = False |
| 63 | self.characters.extend((char, self.stack[-1]) for char in data) |
| 64 | |
| 65 | |
| 66 | def native_characters(page, roots): |
| 67 | definitions = {} |
| 68 | for node in page.findall('one:QuickStyleDef', ns): |
| 69 | definitions[node.attrib['index']] = { |
| 70 | **{k: node.get(k, 'false') == 'true' for k in ('bold', 'italic', 'underline', 'superscript', 'subscript')}, |
| 71 | 'strike': node.get('strikethrough', 'false') == 'true', |
| 72 | **{k: node.attrib[v] for k, v in [('font', 'font'), ('color', 'fontColor'), ('highlight', 'highlightColor')] if v in node.attrib}, |
| 73 | } |
| 74 | if 'fontSize' in node.attrib: |
| 75 | definitions[node.attrib['index']]['font_size'] = float(node.attrib['fontSize']) |
| 76 | result = [] |
| 77 | for root in roots: |
| 78 | pending = [(root, {})] |
| 79 | while pending: |
| 80 | node, inherited = pending.pop() |
| 81 | style = {**inherited, **definitions.get(node.get('quickStyleIndex'), {}), **css(node.get('style', ''))} |
| 82 | if node.tag == '{' + ns['one'] + '}T': |
| 83 | result.append(Runs(node.text or '', style).characters) |
| 84 | pending.extend((child, style) for child in reversed(node)) |
| 85 | return result |
| 86 | |
| 87 | |
| 88 | def compare_formats(space, nodes, native): |
| 89 | count = 0 |
| 90 | differences = {} |
| 91 | for node_index, (node, expected) in enumerate(zip(nodes, native, strict=True)): |
| 92 | kind = node['kind'] |
| 93 | base = space['nodes'][kind['paragraph_style']]['format'] if kind['paragraph_style'] else {} |
| 94 | base = {**{k: v for k, v in base.items() if v is not None}, **{k: v for k, v in node['format'].items() if v is not None}} |
| 95 | source = kind['text'].encode('utf-16-le') |
| 96 | actual = [] |
| 97 | for run in kind['runs']: |
| 98 | style = {**base, **({k: v for k, v in space['nodes'][run['format']]['format'].items() if v is not None} if run['format'] else {})} |
| 99 | text = source[run['start'] * 2:run['end'] * 2].decode('utf-16-le') |
| 100 | # Hidden runs and equation runs (exported as MathML) have no visible native text. |
| 101 | if not style.get('hidden') and not style.get('math'): |
| 102 | actual.extend((char, style) for char in text) |
| 103 | # A trailing CR ends the stored text; one between equations is a line break. |
| 104 | if actual and actual[-1][0] == '\r' and kind['text'].endswith('\r'): |
| 105 | actual.pop() |
| 106 | actual = [(projected, style) for char, style in actual for projected in project_text(char)] |
| 107 | if kind['text'] == '\u00a0' and not expected: |
| 108 | continue |
| 109 | assert ''.join(c for c, _ in actual) == ''.join(c for c, _ in expected), 'Visible text runs differ' |
| 110 | for position, ((_, observed), (_, wanted)) in enumerate(zip(actual, expected, strict=True)): |
| 111 | for key in ('bold', 'italic', 'underline', 'strike', 'superscript', 'subscript', 'font', 'font_size', 'color', 'highlight'): |
| 112 | if key not in observed: |
| 113 | continue |
| 114 | value = observed[key] |
| 115 | if key in ('color', 'highlight'): |
| 116 | if value == 0xff000000: |
| 117 | continue |
| 118 | value = '#' + ''.join(f'{(value >> (8 * i)) & 255:02x}' for i in range(3)) |
| 119 | if observed.get('hyperlink') and key not in wanted: |
| 120 | continue |
| 121 | expected_value = wanted.get(key, False if isinstance(value, bool) else None) |
| 122 | if key in ("font", "font_size") and expected_value is None: |
| 123 | continue |
| 124 | if key in ("color", "highlight") and expected_value not in (None, "automatic", "none"): |
| 125 | expected_value = "#" + "".join(f"{v:02x}" for v in ImageColor.getrgb(expected_value)) |
| 126 | if value != expected_value: |
| 127 | differences.setdefault((node_index, key, value, expected_value), []).append(position) |
| 128 | count += 1 |
| 129 | return count, [{"paragraph": node, "field": key, "stored": actual, "native": expected, |
| 130 | "characters": len(positions), "positions": positions} |
| 131 | for (node, key, actual, expected), positions in differences.items()] |