├── .gitignore ├── README.md ├── letter_a.svg ├── requirements.txt ├── svg2notability.py ├── template.note ├── test_to_notability.py └── to_notability.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | vendor 3 | .ipynb_checkpoints 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### SVG (or PDF) to notability 2 | 3 | This is a very hacky Python script that converts from PDF format to the proprietary Notability 4 | format (Notability is an iOS note-taking app), so that you can **edit** (not just annotate) the 5 | resulting files in Notability. 6 | 7 | This was written for my own personal use (converting handwritten documents exported from Squid on 8 | Android) and may or may not work for anyone else. Sharing this because I spent 5ish hours writing 9 | it, in the hopes that it may be useful to someone else. 10 | 11 | ### requirements 12 | 13 | * plistutil on Linux 14 | * Inkscape 15 | 16 | ### usage 17 | 18 | ``` 19 | pip install -r requirements.txt 20 | # creates my-file.note in current directory 21 | python svg2notability.py my-file.pdf 22 | # works with svg or pdf 23 | python svg2notability.py my-file.svg 24 | ``` 25 | 26 | ### limitations 27 | 28 | * only handles an extremely limited subset of SVGs. your average SVG will probably not work 29 | * can't create notes with multiple pages 30 | * gets your page sizes wrong 31 | * doesn't handle squares 32 | * probably lots more things I'm leaving out 33 | -------------------------------------------------------------------------------- /letter_a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 58 | 63 | 68 | 73 | 78 | 83 | 88 | 93 | 98 | 103 | 108 | 113 | 118 | 123 | 124 | 127 | 132 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==17.4.0 2 | backports-abc==0.5 3 | backports.shutil-get-terminal-size==1.0.0 4 | bleach==2.1.3 5 | configparser==3.5.0 6 | decorator==4.2.1 7 | deepdiff==3.3.0 8 | entrypoints==0.2.3 9 | enum34==1.1.6 10 | funcsigs==1.0.2 11 | functools32==3.2.3.post2 12 | futures==3.2.0 13 | html5lib==1.0.1 14 | ipykernel==4.8.2 15 | ipython==5.5.0 16 | ipython-genutils==0.2.0 17 | ipywidgets==7.2.0 18 | Jinja2==2.10 19 | jsonpickle==0.9.6 20 | jsonschema==2.6.0 21 | jupyter==1.0.0 22 | jupyter-client==5.2.3 23 | jupyter-console==5.2.0 24 | jupyter-core==4.4.0 25 | MarkupSafe==1.0 26 | mistune==0.8.3 27 | more-itertools==4.1.0 28 | nbconvert==5.3.1 29 | nbformat==4.4.0 30 | notebook==5.4.1 31 | numpy==1.14.2 32 | pandocfilters==1.4.2 33 | pathlib2==2.3.0 34 | pexpect==4.4.0 35 | pickleshare==0.7.4 36 | pkg-resources==0.0.0 37 | pluggy==0.6.0 38 | prompt-toolkit==1.0.15 39 | ptyprocess==0.5.2 40 | py==1.5.3 41 | Pygments==2.2.0 42 | pyparsing==2.2.0 43 | pytest==3.5.0 44 | python-dateutil==2.7.2 45 | pyzmq==17.0.0 46 | qtconsole==4.3.1 47 | scandir==1.7 48 | Send2Trash==1.5.0 49 | simplegeneric==0.8.1 50 | singledispatch==3.4.0.3 51 | six==1.11.0 52 | svgpathtools==1.3.2 53 | svgwrite==1.1.12 54 | terminado==0.8.1 55 | testpath==0.3.1 56 | tornado==5.0.1 57 | traitlets==4.3.2 58 | wcwidth==0.1.7 59 | webencodings==0.5.1 60 | widgetsnbextension==3.2.0 61 | -------------------------------------------------------------------------------- /svg2notability.py: -------------------------------------------------------------------------------- 1 | from to_notability import * 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import os.path 6 | 7 | 8 | def convert(filename): 9 | tempdir = tempfile.mkdtemp() 10 | if filename.endswith('svg'): 11 | svg_filename = filename 12 | elif filename.endswith('pdf'): 13 | svg_filename = os.path.join(tempdir, "temp.svg") 14 | subprocess.check_call(['inkscape', filename, "--export-plain-svg=" + svg_filename]) 15 | new_name = os.path.basename(filename).replace('.pdf', '').replace('.svg', '') 16 | paths = get_paths(svg_filename) 17 | aggregated_paths = aggregate_paths(paths) 18 | aggregated_paths = [x for x in aggregated_paths if len(x.points) >= 1] 19 | max_y = max((y.imag for x in aggregated_paths for y in x.points)) 20 | reversed_aggregated_paths = [Path(points=[p.real + 1j * (max_y- p.imag) for p in x.points], attrs=x.attrs) for x in aggregated_paths] 21 | create_zip_file(new_name, plist_from_aggregated_paths(reversed_aggregated_paths, new_name)) 22 | 23 | if len(sys.argv) < 2: 24 | print "OH NO" 25 | convert(sys.argv[1]) 26 | 27 | -------------------------------------------------------------------------------- /template.note: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jvns/svg2notability/5a9f4ff13d8d8674f9dbef5bea889de36a41ae45/template.note -------------------------------------------------------------------------------- /test_to_notability.py: -------------------------------------------------------------------------------- 1 | from to_notability import * 2 | 3 | TEST_AGGREGATED_PATH = [Path(points=[(137.0359344482422+148.5749969482422j), 4 | (137.01373291015625+148.61776733398438j), (136.99362182617188+148.6476287841797j), 5 | (136.97567749023438+148.66586303710938j), (136.95774841308594+148.68409729003906j), 6 | (136.94198608398438+148.69070434570312j), (136.928466796875+148.68699645996094j), 7 | (136.9149627685547+148.6833038330078j), (136.90370178222656+148.66928100585938j), 8 | (136.894775390625+148.64625549316406j), (136.88583374023438+148.6232147216797j), 9 | (136.87924194335938+148.59117126464844j), (136.87506103515625+148.55140686035156j), 10 | (136.8583221435547+148.39236450195312j), (136.88015747070312+148.1099395751953j), 11 | (136.94569396972656+147.78749084472656j), (137.40087890625+146.22169494628906j), 12 | (138.99148559570312+143.58482360839844j), (140.24334716796875+142.3515625j), 13 | (140.70741271972656+141.88172912597656j), (141.162841796875+141.59092712402344j), 14 | (141.67889404296875+141.5203094482422j), (142.765625+141.4709014892578j), 15 | (143.94325256347656+142.63539123535156j), (145.58358764648438+143.42343139648438j), 16 | (146.503662109375+143.81187438964844j), (147.47784423828125+144.09213256835938j), 17 | (148.37265014648438+144.16717529296875j), (148.75216674804688+144.2424774169922j), 18 | (149.12530517578125+144.28125j), (149.48828125+144.2109375j), 19 | (150.8194122314453+143.6942901611328j), (152.1039581298828+141.7109832763672j), 20 | (153.24530029296875+139.0593719482422j)], attrs=Attrs(width='3.499999761581421', color='#006fff'))] 21 | 22 | def test_curve_properties(): 23 | curve_properties = generate_curve_properties(TEST_AGGREGATED_PATH) 24 | assert curve_properties.points == '3\t\tC3\x93\x14C\x84\x03\tC&\x9e\x14C^\xfe\x08C\xcb\xa5\x14C\xc6\xf9\x08Cv\xaa\x14C/\xf5\x08C!\xaf\x14C&\xf1\x08C\xd2\xb0\x14C\xb0\xed\x08C\xdf\xaf\x14C;\xea\x08C\xed\xae\x14CY\xe7\x08CV\xab\x14C\x10\xe5\x08Cq\xa5\x14C\xc6\xe2\x08C\x8b\x9f\x14C\x16\xe1\x08CW\x97\x14C\x04\xe0\x08C)\x8d\x14C\xbb\xdb\x08Crd\x14CR\xe1\x08C%\x1c\x14C\x19\xf2\x08C\x99\xc9\x13C\xa0f\tC\xc18\x12C\xd2\xfd\nC\xb7\x95\x0fCL>\x0cC\x00Z\x0eC\x19\xb5\x0cC\xb9\xe1\rC\xb0)\rCG\x97\rC\xcc\xad\rC3\x85\rC\x00\xc4\x0eC\x8dx\rCy\xf1\x0fC\xa9\xa2\x0eCf\x95\x11Cfl\x0fC\xf0\x80\x12C\xd7\xcf\x0fCTz\x13C\x96\x17\x10Cf_\x14C\xcc*\x10C\x8e\xc0\x14C\x13>\x10C\x14 \x15C\x00H\x10C\x00}\x15C\x006\x10C\xc5\xd1\x16C\xbd\xb1\x0fC\x9d\x1a\x18C\x03\xb6\rC\xcc>\x19C3\x0f\x0bC' 25 | assert curve_properties.colors == '\x00o\xff\xff' 26 | assert curve_properties.numpoints == '"\x00\x00\x00' 27 | assert curve_properties.width == '\xff\xff_@' 28 | assert curve_properties.fractionalwidths == '\x00\x00\x80?' * 12 29 | 30 | 31 | def test_letter_a(): 32 | paths = get_paths('letter_a.svg') 33 | result = aggregate_paths(paths) 34 | assert('40.17' in str(result)) 35 | -------------------------------------------------------------------------------- /to_notability.py: -------------------------------------------------------------------------------- 1 | import svgpathtools 2 | from collections import namedtuple 3 | import zipfile 4 | import plistlib 5 | import subprocess 6 | import contextlib 7 | import os 8 | import os.path 9 | import tempfile 10 | import numpy as np 11 | import itertools 12 | import struct 13 | 14 | Path = namedtuple('Path', 'points attrs') 15 | Attrs = namedtuple('Attrs', 'width color') 16 | 17 | # plist methods 18 | 19 | def base_notability_file(filename=None): 20 | base_dir = os.path.dirname(__file__) 21 | if filename is None: 22 | filename = os.path.join(base_dir, './template.note') 23 | return zipfile.ZipFile(filename, 'r') 24 | 25 | def base_plist(filename=None): 26 | temp_dir = tempfile.mkdtemp() # todo: remove dir 27 | with base_notability_file(filename=filename) as zipf: 28 | start = os.path.basename(zipf.filename).replace('.note', '') 29 | if filename is None: 30 | start = 'reverse' 31 | extracted_filename = zipf.extract(os.path.join(start,'Session.plist'), temp_dir) 32 | return plistlib.readPlistFromString(subprocess.check_output(['plistutil', '-i', extracted_filename])) 33 | 34 | def create_zip_file(new_name, pl): 35 | note_filename = '{name}.note'.format(name=new_name) 36 | temp_dir = tempfile.mkdtemp() # todo: remove dir 37 | xml_file = os.path.join(temp_dir, 'plist.xml') 38 | plistlib.writePlist(pl, xml_file) 39 | with base_notability_file() as zipf: 40 | zipf.extractall(temp_dir) 41 | subprocess.check_call(['mv', os.path.join(temp_dir, 'reverse'), os.path.join(temp_dir, new_name) ]) 42 | subprocess.check_output(['plistutil', '-i', xml_file, '-o', os.path.join(temp_dir, new_name, "Session.plist")]) 43 | with remember_cwd(): 44 | os.chdir(temp_dir) 45 | subprocess.check_call(['zip', '-r', note_filename, new_name + '/']) 46 | subprocess.check_call(['mv', os.path.join(temp_dir, note_filename), note_filename ]) 47 | return note_filename 48 | 49 | @contextlib.contextmanager 50 | def remember_cwd(): 51 | curdir= os.getcwd() 52 | try: yield 53 | finally: os.chdir(curdir) 54 | 55 | # converting 56 | 57 | CurveProperties = namedtuple('CurveProperties', 'colors width numpoints fractionalwidths points event_tokens count_fracwidths count_curves count_points') 58 | 59 | def pack_struct(l, fmt): 60 | return struct.pack('{num}{format}'.format(num=len(l), format=fmt), *l) 61 | 62 | def render_color(hexcode): 63 | try: 64 | return struct.pack('>I', int(hexcode.replace('#', '') + 'ff', 16)) 65 | except: 66 | return '\x00\x00\x00\xff' 67 | 68 | def generate_curve_properties(aggregated_paths): 69 | num_curves = len(aggregated_paths) 70 | num_points = np.array([len(x.points) for x in aggregated_paths]) 71 | curvesfractionalwidths = np.zeros(sum(np.maximum(num_points / 3 + 1, 2)), dtype=float) + 1.0 72 | x_max = max(y.real for x in aggregated_paths for y in x.points) 73 | x_limit = 450 74 | scaling = x_limit / x_max 75 | curveswidths = np.array([float(x.attrs.width) if x.attrs.width is not None else 1.0 for x in aggregated_paths]) 76 | points = np.array(list(itertools.chain(*[[y.real, y.imag] for x in aggregated_paths for y in x.points]))) 77 | colors = ''.join([render_color(x.attrs.color) for x in aggregated_paths]) 78 | return CurveProperties( 79 | colors=colors, 80 | width=pack_struct(scaling * curveswidths, 'f'), 81 | event_tokens = '\xff\xff\xff\xff' * num_curves, 82 | numpoints=pack_struct(num_points, 'i'), 83 | fractionalwidths=struct.pack('%sf' % len(curvesfractionalwidths), *curvesfractionalwidths), 84 | points=pack_struct(scaling * points, 'f'), 85 | count_fracwidths = len(curvesfractionalwidths), 86 | count_curves = num_curves, 87 | count_points = len(points)/2, 88 | ) 89 | 90 | 91 | def plist_from_aggregated_paths(aggregated_paths, new_name): 92 | pl = base_plist() 93 | objects = pl['$objects'] 94 | objects[-8] = new_name 95 | path_data = objects[8] 96 | properties = generate_curve_properties(aggregated_paths) 97 | path_data['curvescolors'] = plistlib.Data(properties.colors) 98 | path_data['curvespoints'] = plistlib.Data(properties.points) 99 | path_data['curveswidth'] = plistlib.Data(properties.width) 100 | path_data['curvesnumpoints'] = plistlib.Data(properties.numpoints) 101 | path_data['curvesfractionalwidths'] = plistlib.Data(properties.fractionalwidths) 102 | path_data['eventTokens'] = plistlib.Data(properties.event_tokens) 103 | objects[9] = properties.count_fracwidths 104 | objects[10] = properties.count_curves 105 | objects[11] = properties.count_points 106 | return pl 107 | 108 | # svg parsing methods 109 | 110 | def parse_attrs(attrs): 111 | style = attrs.get('style') 112 | if style is None: 113 | return Attrs(color=None, width=None) 114 | style = dict([x.split(':') for x in style.split(';')]) 115 | return Attrs(color= style.get('stroke'), width = style.get('stroke-width')) 116 | 117 | def is_different(prev, current, prev_attrs, current_attrs): 118 | if prev_attrs != current_attrs: 119 | return False 120 | if prev is None: 121 | return True 122 | diff = (prev.end - current.start) 123 | (x,y) = (diff.real, diff.imag) 124 | return abs(x) < 1e-10 and abs(y) < 1e-10 125 | 126 | def lengthen_path(current_points): 127 | if len(current_points) < 4 and len(current_points) > 0: 128 | start = current_points[0] 129 | end = current_points[-1] 130 | return [start + 0.01, start + 0.01j] + current_points + [end - 0.01, end - 0.01j] 131 | return current_points 132 | 133 | INTERPOLATE=14 134 | def aggregate_paths(paths): 135 | prev = None 136 | all_paths = [] 137 | current_points = [] 138 | current_attrs = Attrs(width=None, color=None) 139 | for path, attrs in paths: 140 | attrs = parse_attrs(attrs) 141 | if is_different(prev, path, current_attrs, attrs): 142 | for i in range(1, INTERPOLATE+1): 143 | current_points.append(path.start * (INTERPOLATE-i)/INTERPOLATE + path.end * i/INTERPOLATE) 144 | else: 145 | all_paths.append(Path(points=lengthen_path(current_points), attrs=current_attrs)) 146 | if path.start == path.end: 147 | current_points = [path.start + 0.01, path.start + 0.01j, path.start - 0.01, path.start - 0.01j] 148 | else: 149 | current_points = [path.start, path.end] # todo: not right, some paths actually have multiple points *inside* them 150 | current_attrs = attrs 151 | prev = path 152 | 153 | all_paths.append(Path(points=lengthen_path(current_points), attrs=current_attrs)) 154 | return all_paths 155 | 156 | def get_paths(filename): 157 | paths, attrs = svgpathtools.svg2paths(filename) 158 | return zip(paths, attrs) 159 | --------------------------------------------------------------------------------