| 1 | """Pressure ink as OneNote 2010 keeps and draws it (`corpus/ink-pressure`), read from its PDF |
| 2 | exports: every point of a stroke is a round stamp whose diameter is the pen's width times |
| 3 | 0.25 + 1.5 * pressure.""" |
| 4 | from collections import defaultdict |
| 5 | from pathlib import Path |
| 6 | import re |
| 7 | import runpy |
| 8 | import shutil |
| 9 | from tempfile import TemporaryDirectory |
| 10 | import unittest |
| 11 | import xml.etree.ElementTree as ET |
| 12 | import zlib |
| 13 | |
| 14 | from native_xml import ns |
| 15 | |
| 16 | ROOT = Path(__file__).resolve().parent.parent |
| 17 | FIXTURE = ROOT / 'corpus/ink-pressure' |
| 18 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] |
| 19 | NUMBER = r'(-?[\d.]+)' |
| 20 | STAMP = 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 | |
| 24 | def 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 | |
| 33 | def 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 | |
| 46 | def 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 | |
| 54 | def thickness(pressure): |
| 55 | return 0.25 + 1.5 * pressure |
| 56 | |
| 57 | |
| 58 | class 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 | |
| 111 | if __name__ == '__main__': |
| 112 | unittest.main() |