1"""Independent formatting oracle from the application's XML and inline HTML."""
2from html.parser import HTMLParser
3from native_xml import ns, project_text
4from PIL import ImageColor
5
6
7def 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
29class 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
66def 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
88def 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()]