1"""Pressure ink as OneNote 2010 keeps and draws it (`corpus/ink-pressure`), read from its PDF
2exports: every point of a stroke is a round stamp whose diameter is the pen's width times
30.25 + 1.5 * pressure."""
4from collections import defaultdict
5from pathlib import Path
6import re
7import runpy
8import shutil
9from tempfile import TemporaryDirectory
10import unittest
11import xml.etree.ElementTree as ET
12import zlib
13
14from native_xml import ns
15
16ROOT = Path(__file__).resolve().parent.parent
17FIXTURE = ROOT / 'corpus/ink-pressure'
18compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
19NUMBER = r'(-?[\d.]+)'
20STAMP = re.compile(rf'{NUMBER} {NUMBER} m\s+{NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} c\s+'
21 rf'{NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} {NUMBER} c')
22
23
24def page(read, name):
25 """A page's XML and its PDF export."""
26 for path in sorted(read.glob('page-*.xml')):
27 root = ET.parse(path).getroot()
28 if root.get('name') == name:
29 return root, path.with_suffix('.pdf')
30 raise KeyError(name)
31
32
33def stamps(pdf):
34 """Each round stamp OneNote exports, as (bottom, diameter) in PDF points."""
35 data = pdf.read_bytes()
36 streams = []
37 for match in re.finditer(rb'stream\r?\n(.*?)\r?\nendstream', data, re.S):
38 try:
39 streams.append(zlib.decompress(match.group(1)).decode('latin1'))
40 except zlib.error:
41 pass
42 return [(float(m.group(8)), float(m.group(1)) - float(m.group(13)))
43 for m in STAMP.finditer('\n'.join(streams))]
44
45
46def rows(pdf):
47 """Stamp diameters by the height of their bottom edge, rounded to a tenth of a point."""
48 found = defaultdict(list)
49 for bottom, diameter in stamps(pdf):
50 found[round(bottom, 1)].append(diameter)
51 return found
52
53
54def thickness(pressure):
55 return 0.25 + 1.5 * pressure
56
57
58class InkPressureTest(unittest.TestCase):
59 def test_onenote_draws_a_quarter_of_the_pen_at_no_pressure_and_1_75_times_at_full(self):
60 _, pdf = page(FIXTURE / 'native/read', 'Levels')
61 # Two drawings of nine 24-point strokes at pressures 0 to 1 in eighths: one pen reports
62 # 1024 levels, the other 256, and both draw alike.
63 found = sorted(rows(pdf).items(), reverse=True)
64 diameters = [d for _, row in found for d in row]
65 self.assertEqual(len(diameters), 2 * 9 * 5)
66 eighths = [((d / 24 - 0.25) / 1.5) * 8 for d in diameters]
67 for eighth in eighths:
68 self.assertAlmostEqual(eighth, round(eighth), delta=0.03)
69 self.assertEqual(sorted(round(e) for e in eighths), sorted(list(range(9)) * 10))
70
71 def test_a_pen_ignoring_pressure_draws_one_width(self):
72 root, _ = page(FIXTURE / 'native/read', 'Strokes')
73 self.assertEqual(len(root.findall('one:InkDrawing', ns)), 5)
74 self.assertTrue((FIXTURE / 'native/read/page-000.png').exists())
75
76 def test_snowbound_pressure_strokes_open_in_onenote_at_their_widths(self):
77 with TemporaryDirectory() as temporary:
78 read = Path(temporary) / 'read'
79 shutil.copytree(FIXTURE / 'cold/read', read)
80 compare(FIXTURE / 'candidate', read)
81 _, pdf = page(FIXTURE / 'cold/read', 'Levels')
82 found = rows(pdf)
83 # Snowbound's strokes of a 500 HIMETRIC pen at none, half and full pressure.
84 pen = 500 * 72 / 2540
85 snowbound = [row for bottom, row in found.items() if len(row) == 3]
86 self.assertEqual(len(snowbound), 3)
87 for pressure, row in zip([1.0, 0.5, 0.0], sorted(snowbound, key=lambda row: row[0], reverse=True)):
88 for diameter in row:
89 self.assertAlmostEqual(diameter, pen * thickness(pressure), delta=0.06)
90 # Its ramp widens point by point from a quarter of the pen to 1.75 times it.
91 ramp = sorted(d for _, row in found.items() if len(row) == 1 for d in row)
92 self.assertEqual(len(ramp), 41)
93 self.assertAlmostEqual(ramp[0], pen * 0.25, delta=0.06)
94 self.assertAlmostEqual(ramp[-1], pen * 1.75, delta=0.06)
95
96 def test_onenote_pressure_ink_keeps_its_widths_after_snowbound_edits(self):
97 before = rows(page(FIXTURE / 'native/read', 'Levels')[1])
98 after = rows(page(FIXTURE / 'cold/read', 'Levels')[1])
99 # The erased stroke at no pressure leaves the 1024-level drawing; the rest draw as before.
100 onenote = lambda found: sorted(round(d, 1) for row in found.values() if len(row) in (5, 10) for d in row)
101 erased = onenote(before)
102 for _ in range(5):
103 erased.remove(6.0)
104 self.assertEqual(onenote(after), erased)
105 drawings = page(FIXTURE / 'cold/read', 'Strokes')[0].findall('one:InkDrawing', ns)
106 self.assertEqual(len(drawings), 4)
107 position = drawings[0].find('one:Position', ns)
108 self.assertEqual((float(position.get('x')), float(position.get('y'))), (72.0, 108.0))
109
110
111if __name__ == '__main__':
112 unittest.main()