├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml └── supernotelib ├── __init__.py ├── cmds ├── __init__.py ├── supernote_fuse.py └── supernote_tool.py ├── color.py ├── converter.py ├── decoder.py ├── exceptions.py ├── fileformat.py ├── manipulator.py ├── parser.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # supernote-tool 2 | 3 | `supernote-tool` is an unofficial python tool for [Ratta Supernote](https://supernote.com). 4 | It allows converting a Supernote's `*.note` file into PNG image file 5 | without operating export function on a real device. 6 | 7 | This tool is under development and may change. 8 | 9 | 10 | ## Installation 11 | 12 | ``` 13 | $ pip install supernotelib 14 | ``` 15 | 16 | 17 | ## Usage 18 | 19 | To convert first page of your note into PNG image: 20 | 21 | ``` 22 | $ supernote-tool convert your.note output.png 23 | ``` 24 | 25 | To convert all pages: 26 | 27 | ``` 28 | $ supernote-tool convert -a your.note output.png 29 | ``` 30 | 31 | If you want to specify page number to convert: 32 | 33 | ``` 34 | $ supernote-tool convert -n 3 your.note output.png 35 | ``` 36 | 37 | You can colorize a note by specifing alternative color codes in order of black, darkgray, gray and white. 38 | Note that use `#fefefe` for white because `#ffffff` is used for transparent. 39 | 40 | To convert black into red: 41 | 42 | ``` 43 | $ supernote-tool convert -c "#ff0000,#9d9d9d,#c9c9c9,#fefefe" your.note output.png 44 | ``` 45 | 46 | To convert into SVG file format: 47 | 48 | ``` 49 | $ supernote-tool convert -t svg your.note output.svg 50 | ``` 51 | 52 | To convert all pages into PDF file format: 53 | 54 | ``` 55 | $ supernote-tool convert -t pdf -a your.note output.pdf 56 | ``` 57 | 58 | You can also convert your handwriting to vector format and save it as PDF with `--pdf-type vector` option. 59 | Note that converting to a vector takes time. 60 | 61 | ``` 62 | $ supernote-tool convert -t pdf --pdf-type vector -a your.note output.pdf 63 | ``` 64 | 65 | To extract text from a real-time recognition note introduced from Chauvet2.7.21: 66 | 67 | ``` 68 | $ supernote-tool convert -t txt -a your.note output.txt 69 | ``` 70 | 71 | You can specify a page separator string for text conversion: 72 | 73 | ``` 74 | $ supernote-tool convert -t txt -a --text-page-separator='----' your.note output.txt 75 | ``` 76 | 77 | For developers, dump note metadata as JSON format: 78 | 79 | ``` 80 | $ supernote-tool analyze your.note 81 | ``` 82 | 83 | 84 | ## Supporting files 85 | 86 | * `*.note` file created on Supernote A5 (Firmware SN100.B000.432_release) 87 | * `*.note` file created on Supernote A6X (Firmware Chauvet 2.18.29) 88 | * `*.note` file created on Supernote A5X (Firmware Chauvet 2.18.29) 89 | * `*.note` file created on Supernote A6X2 (Firmware Chauvet 3.20.29) 90 | * `*.note` file created on Supernote A5X2 (Firmware Chauvet 3.20.29) 91 | 92 | ## License 93 | 94 | This software is released under the Apache License 2.0, see [LICENSE](LICENSE) file for details. 95 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "supernotelib" 7 | description = "An unofficial converter library for Ratta Supernote" 8 | readme = "README.md" 9 | license = { file = "LICENSE" } 10 | requires-python = ">=3.6" 11 | keywords = [ 12 | "supernote", 13 | ] 14 | authors = [ 15 | { name = "jya", email = "jya@wizmy.net" }, 16 | ] 17 | classifiers = [ 18 | "Development Status :: 4 - Beta", 19 | "Intended Audience :: Developers", 20 | "Intended Audience :: End Users/Desktop", 21 | "License :: OSI Approved :: Apache Software License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.6", 26 | "Programming Language :: Python :: 3.7", 27 | "Programming Language :: Python :: 3.8", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Topic :: Multimedia :: Graphics", 31 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 32 | "Topic :: Utilities", 33 | ] 34 | dependencies = [ 35 | "colour>=0.1.5", 36 | "fusepy>=3.0.1", 37 | "numpy>=1.19.0", 38 | "Pillow>=7.2.0", 39 | "potracer>=0.0.1", 40 | "pypng>=0.0.20", 41 | "reportlab>=3.6.1", 42 | "svglib>=1.1.0", 43 | "svgwrite>=1.4", 44 | ] 45 | dynamic = ["version"] 46 | 47 | [project.urls] 48 | homepage = "https://github.com/jya-dev/supernote-tool" 49 | 50 | [project.scripts] 51 | supernote-tool = "supernotelib.cmds.supernote_tool:main" 52 | supernote-fuse = "supernotelib.cmds.supernote_fuse:main" 53 | 54 | [tool.hatch.version] 55 | path = "supernotelib/__init__.py" 56 | -------------------------------------------------------------------------------- /supernotelib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | __version__ = '0.6.2' 16 | 17 | from . import color 18 | from . import converter 19 | 20 | from .exceptions import * 21 | from .fileformat import * 22 | from .manipulator import * 23 | from .parser import * 24 | -------------------------------------------------------------------------------- /supernotelib/cmds/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jya-dev/supernote-tool/73166ca4fbe23a50e188c212f7f1a8d30ce1ce66/supernotelib/cmds/__init__.py -------------------------------------------------------------------------------- /supernotelib/cmds/supernote_fuse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) 2020 Ted M Lin 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import logging 18 | 19 | from collections import defaultdict 20 | from errno import EACCES 21 | from threading import Lock 22 | 23 | import sys 24 | import io 25 | 26 | import os 27 | import os.path 28 | 29 | from fuse import FUSE, FuseOSError, Operations, LoggingMixIn 30 | 31 | import supernotelib as sn 32 | 33 | def is_pdf_note(path): 34 | if not path.endswith('.pdf') or os.path.exists(path): 35 | return False 36 | return os.path.exists(path[:-3] + 'note') 37 | 38 | def path_from_pdf_note(path): 39 | if is_pdf_note(path): 40 | return path[:-3] + 'note' 41 | return path 42 | 43 | class NoteToPdf(LoggingMixIn, Operations): 44 | def __init__(self, root): 45 | self.root = os.path.realpath(root) 46 | self.rwlock = Lock() 47 | self.files = {} 48 | self.data = defaultdict(bytes) 49 | 50 | def __call__(self, op, path, *args): 51 | return super(NoteToPdf, self).__call__(op, self.root + path, *args) 52 | 53 | def access(self, path, mode): 54 | path = path_from_pdf_note(path) 55 | if not os.access(path, mode): 56 | raise FuseOSError(EACCES) 57 | 58 | chmod = None 59 | chown = None 60 | create = None 61 | flush = None 62 | fsync = None 63 | 64 | def getattr(self, path, fh=None): 65 | if path in self.files: 66 | return self.files[path] 67 | 68 | path = path_from_pdf_note(path) 69 | st = os.lstat(path) 70 | return dict((key, getattr(st, key)) for key in ('st_atime', 'st_ctime', 71 | 'st_gid', 'st_mode', 'st_mtime', 'st_nlink', 'st_size', 'st_uid')) 72 | 73 | getxattr = None 74 | link = None 75 | mkdir = None 76 | mknod = None 77 | 78 | def open(self, path, flags): 79 | if is_pdf_note(path): 80 | pdfpath = path 81 | path = path_from_pdf_note(path) 82 | 83 | # check if underlying file has changed 84 | updatecache = True 85 | if pdfpath in self.files: 86 | st = os.lstat(path) 87 | if st.st_mtime == self.files[pdfpath]['st_mtime']: 88 | updatecache = False 89 | 90 | if updatecache: 91 | notebook = sn.load_notebook(path) 92 | converter = sn.converter.ImageConverter(notebook) 93 | 94 | imglist = [] 95 | total = notebook.get_total_pages() 96 | for i in range(total): 97 | img = converter.convert(i) 98 | imglist.append(img.convert('RGB')) 99 | 100 | # TODO: can a note have zero pages? or fail? 101 | # ... generate a pdf with "error message"? 102 | buf = io.BytesIO() 103 | imglist[0].save(buf, format='PDF', save_all=True, append_images=imglist[1:]) 104 | 105 | self.data[pdfpath] = buf.getvalue() 106 | 107 | self.files[pdfpath] = self.getattr(path) 108 | self.files[pdfpath]['st_size'] = len(self.data[pdfpath]) 109 | 110 | # always open the original file (to get a handle) 111 | return os.open(path, flags) 112 | 113 | def read(self, path, size, offset, fh): 114 | with self.rwlock: 115 | if path in self.data: 116 | return self.data[path][offset:offset + size] 117 | 118 | os.lseek(fh, offset, 0) 119 | return os.read(fh, size) 120 | 121 | def readdir(self, path, fh): 122 | entries = [] 123 | with os.scandir(path) as it: 124 | for entry in it: 125 | # passthrough all files, making a special pdf note 126 | entries.append(entry.name) 127 | if entry.name.endswith('.note'): 128 | entries.append(entry.name[:-4] + 'pdf') 129 | return entries 130 | 131 | readlink = None 132 | 133 | def release(self, path, fh): 134 | return os.close(fh) 135 | 136 | rename = None 137 | rmdir = None 138 | 139 | def statfs(self, path): 140 | path = path_from_pdf_note(path) 141 | stv = os.statvfs(path) 142 | return dict((key, getattr(stv, key)) for key in ('f_bavail', 'f_bfree', 143 | 'f_blocks', 'f_bsize', 'f_favail', 'f_ffree', 'f_files', 'f_flag', 144 | 'f_frsize', 'f_namemax')) 145 | 146 | symlink = None 147 | truncate = None 148 | unlink = None 149 | utimens = os.utime 150 | write = None 151 | 152 | def main(): 153 | if len(sys.argv) != 3: 154 | print('usage: %s ' % sys.argv[0]) 155 | sys.exit(1) 156 | 157 | logging.basicConfig(level=logging.INFO) 158 | fuse = FUSE(NoteToPdf(sys.argv[1]), sys.argv[2], foreground=True) 159 | 160 | if __name__ == '__main__': 161 | main() 162 | -------------------------------------------------------------------------------- /supernotelib/cmds/supernote_tool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) 2020 jya 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import argparse 18 | import io 19 | import os 20 | import sys 21 | 22 | from colour import Color 23 | 24 | import supernotelib as sn 25 | from supernotelib.converter import ImageConverter, SvgConverter, PdfConverter, TextConverter 26 | from supernotelib.converter import VisibilityOverlay 27 | 28 | def convert_all(converter, total, file_name, save_func, visibility_overlay): 29 | basename, extension = os.path.splitext(file_name) 30 | max_digits = len(str(total)) 31 | for i in range(total): 32 | # append page number between filename and extention 33 | numbered_filename = basename + '_' + str(i).zfill(max_digits) + extension 34 | img = converter.convert(i, visibility_overlay) 35 | save_func(img, numbered_filename) 36 | 37 | def convert_and_concat_all(converter, total, file_name, save_func, separator): 38 | data = [] 39 | for i in range(total): 40 | data.append(converter.convert(i)) 41 | data = list(map(lambda x : '' if x is None else (x + '\n'), data)) 42 | if len(data) > 0: 43 | alldata = ((separator + '\n') if separator else '').join(data) 44 | save_func(alldata, file_name) 45 | else: 46 | print('no data') 47 | 48 | def convert_to_png(args, notebook, palette): 49 | converter = ImageConverter(notebook, palette=palette) 50 | bg_visibility = VisibilityOverlay.INVISIBLE if args.exclude_background else VisibilityOverlay.DEFAULT 51 | vo = sn.converter.build_visibility_overlay(background=bg_visibility) 52 | def save(img, file_name): 53 | img.save(file_name, format='PNG') 54 | if args.all: 55 | total = notebook.get_total_pages() 56 | convert_all(converter, total, args.output, save, vo) 57 | else: 58 | img = converter.convert(args.number, visibility_overlay=vo) 59 | save(img, args.output) 60 | 61 | def convert_to_svg(args, notebook, palette): 62 | converter = SvgConverter(notebook, palette=palette) 63 | bg_visibility = VisibilityOverlay.INVISIBLE if args.exclude_background else VisibilityOverlay.DEFAULT 64 | vo = sn.converter.build_visibility_overlay(background=bg_visibility) 65 | def save(svg, file_name): 66 | if svg is not None: 67 | with open(file_name, 'w') as f: 68 | f.write(svg) 69 | else: 70 | print('no path data') 71 | if args.all: 72 | total = notebook.get_total_pages() 73 | convert_all(converter, total, args.output, save, vo) 74 | else: 75 | svg = converter.convert(args.number, visibility_overlay=vo) 76 | save(svg, args.output) 77 | 78 | def convert_to_pdf(args, notebook, palette): 79 | vectorize = args.pdf_type == 'vector' 80 | use_link = not args.no_link 81 | use_keyword = args.add_keyword 82 | converter = PdfConverter(notebook, palette=palette) 83 | def save(data, file_name): 84 | if data is not None: 85 | with open(file_name, 'wb') as f: 86 | f.write(data) 87 | else: 88 | print('no data') 89 | if args.all: 90 | data = converter.convert(-1, vectorize, enable_link=use_link, enable_keyword=use_keyword) # minus value means converting all pages 91 | save(data, args.output) 92 | else: 93 | data = converter.convert(args.number, vectorize, enable_link=use_link, enable_keyword=use_keyword) 94 | save(data, args.output) 95 | 96 | def convert_to_txt(args, notebook, palette): 97 | converter = TextConverter(notebook, palette=palette) 98 | def save(data, file_name): 99 | if data is not None: 100 | with open(file_name, 'w') as f: 101 | f.write(data) 102 | else: 103 | print('no data') 104 | if args.all: 105 | total = notebook.get_total_pages() 106 | convert_and_concat_all(converter, total, args.output, save, args.text_page_separator) 107 | else: 108 | data = converter.convert(args.number) 109 | save(data, args.output) 110 | 111 | def subcommand_convert(args): 112 | notebook = sn.load_notebook(args.input, policy=args.policy) 113 | palette = None 114 | if args.color: 115 | try: 116 | colors = parse_color(args.color) 117 | except ValueError as e: 118 | print(e, file=sys.stderr) 119 | sys.exit(1) 120 | palette = sn.color.ColorPalette(sn.color.MODE_RGB, colors) 121 | if args.type == 'png': convert_to_png(args, notebook, palette) 122 | elif args.type == 'svg': convert_to_svg(args, notebook, palette) 123 | elif args.type == 'pdf': convert_to_pdf(args, notebook, palette) 124 | elif args.type == 'txt': convert_to_txt(args, notebook, palette) 125 | 126 | def subcommand_analyze(args): 127 | # show all metadata as JSON 128 | with open(args.input, 'rb') as f: 129 | metadata = sn.parse_metadata(f, policy=args.policy) 130 | print(metadata.to_json(indent=2)) 131 | 132 | def subcommand_merge(args): 133 | num_input = len(args.input) 134 | if num_input == 1: # reconstruct a note file 135 | notebook = sn.load_notebook(args.input[0]) 136 | reconstructed_binary = sn.reconstruct(notebook) 137 | with open(args.output, 'wb') as f: 138 | f.write(reconstructed_binary) 139 | else: # merge multiple note files 140 | with open(args.input[0], 'rb') as f: 141 | merged_binary = f.read() 142 | for i in range(1, num_input): 143 | stream = io.BytesIO(merged_binary) 144 | merged_notebook = sn.load(stream) 145 | next_notebook = sn.load_notebook(args.input[i]) 146 | merged_binary = sn.merge(merged_notebook, next_notebook) 147 | with open(args.output, 'wb') as f: 148 | f.write(merged_binary) 149 | 150 | def subcommand_reconstruct(args): 151 | notebook = sn.load_notebook(args.input) 152 | reconstructed_binary = sn.reconstruct(notebook) 153 | with open(args.output, 'wb') as f: 154 | f.write(reconstructed_binary) 155 | 156 | def parse_color(color_string): 157 | colorcodes = color_string.split(',') 158 | if len(colorcodes) != 4: 159 | raise ValueError(f'few color codes, 4 colors are required: {color_string}') 160 | black = int(Color(colorcodes[0]).hex_l[1:7], 16) 161 | darkgray = int(Color(colorcodes[1]).hex_l[1:7], 16) 162 | gray = int(Color(colorcodes[2]).hex_l[1:7], 16) 163 | white = int(Color(colorcodes[3]).hex_l[1:7], 16) 164 | return (black, darkgray, gray, white) 165 | 166 | 167 | def main(): 168 | parser = argparse.ArgumentParser(prog='supernote-tool', description='Unofficial python tool for Ratta Supernote') 169 | parser.add_argument('--version', help='show version information and exit', action='version', version=f'%(prog)s {sn.__version__}') 170 | subparsers = parser.add_subparsers() 171 | 172 | # 'analyze' subcommand 173 | parser_analyze = subparsers.add_parser('analyze', help='analyze note file') 174 | parser_analyze.add_argument('input', type=str, help='input note file') 175 | parser_analyze.add_argument('--policy', choices=['strict', 'loose'], default='strict', help='select parser policy') 176 | parser_analyze.set_defaults(handler=subcommand_analyze) 177 | 178 | # 'convert' subcommand 179 | parser_convert = subparsers.add_parser('convert', help='image conversion') 180 | parser_convert.add_argument('input', type=str, help='input note file') 181 | parser_convert.add_argument('output', type=str, help='output image file') 182 | parser_convert.add_argument('-n', '--number', type=int, default=0, help='page number to be converted') 183 | parser_convert.add_argument('-a', '--all', action='store_true', default=False, help='convert all pages') 184 | parser_convert.add_argument('-c', '--color', type=str, help='colorize note with comma separated color codes in order of black, darkgray, gray and white.') 185 | parser_convert.add_argument('-t', '--type', choices=['png', 'svg', 'pdf', 'txt'], default='png', help='select conversion file type') 186 | parser_convert.add_argument('--exclude-background', action='store_true', default=False, help='exclude background and make it transparent (PNG and SVG are supported)') 187 | parser_convert.add_argument('--pdf-type', choices=['original', 'vector'], default='original', help='select PDF conversion type') 188 | parser_convert.add_argument('--no-link', action='store_true', default=False, help='disable links in PDF') 189 | parser_convert.add_argument('--add-keyword', action='store_true', default=False, help='enable keywords in PDF') 190 | parser_convert.add_argument('--text-page-separator', type=str, default='', help='page separator string for text conversion') 191 | parser_convert.add_argument('--policy', choices=['strict', 'loose'], default='strict', help='select parser policy') 192 | parser_convert.set_defaults(handler=subcommand_convert) 193 | 194 | # 'merge' subcommand 195 | description = \ 196 | ''' 197 | (EXPERIMENTAL FEATURE) 198 | This command merge multiple note files to one. 199 | Backup your input files to save your data because you might get a corrupted output file. 200 | ''' 201 | parser_merge = subparsers.add_parser('merge', 202 | description=description, 203 | help='merge multiple note files (EXPERIMENTAL FEATURE)') 204 | parser_merge.add_argument('input', type=str, nargs='+', help='input note files') 205 | parser_merge.add_argument('output', type=str, help='output note file') 206 | parser_merge.set_defaults(handler=subcommand_merge) 207 | 208 | # 'reconstruct' subcommand 209 | description = \ 210 | ''' 211 | (EXPERIMENTAL FEATURE) 212 | This command disassemble and reconstruct a note file for debugging and testing. 213 | Backup your input file to save your data because you might get a corrupted output file. 214 | ''' 215 | parser_reconstruct = subparsers.add_parser('reconstruct', 216 | description=description, 217 | help='reconstruct a note file (EXPERIMENTAL FEATURE)') 218 | parser_reconstruct.add_argument('input', type=str, help='input note file') 219 | parser_reconstruct.add_argument('output', type=str, help='output note file') 220 | parser_reconstruct.set_defaults(handler=subcommand_reconstruct) 221 | 222 | args = parser.parse_args() 223 | if hasattr(args, 'handler'): 224 | args.handler(args) 225 | else: 226 | parser.print_help() 227 | 228 | if __name__ == '__main__': 229 | main() 230 | -------------------------------------------------------------------------------- /supernotelib/color.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Color classes.""" 16 | 17 | # color mode 18 | MODE_GRAYSCALE = 'grayscale' 19 | MODE_RGB = 'rgb' 20 | 21 | # preset grayscale colors 22 | BLACK = 0x00 23 | DARK_GRAY = 0x9d 24 | GRAY = 0xc9 25 | WHITE = 0xfe 26 | TRANSPARENT = 0xff 27 | DARK_GRAY_COMPAT = 0x30 28 | GRAY_COMPAT = 0x50 29 | 30 | # preset RGB colors 31 | RGB_BLACK = 0x000000 32 | RGB_DARK_GRAY = 0x9d9d9d 33 | RGB_GRAY = 0xc9c9c9 34 | RGB_WHITE = 0xfefefe 35 | RGB_TRANSPARENT = 0xffffff 36 | RGB_DARK_GRAY_COMPAT = 0x303030 37 | RGB_GRAY_COMPAT = 0x505050 38 | 39 | 40 | def get_rgb(value): 41 | r = (value & 0xff0000) >> 16 42 | g = (value & 0x00ff00) >> 8 43 | b = value & 0x0000ff 44 | return (r, g, b) 45 | 46 | def web_string(value, mode=MODE_RGB): 47 | if mode == MODE_GRAYSCALE: 48 | return '#' + (format(value & 0xff, '02x') * 3) 49 | else: 50 | r, g, b = get_rgb(value) 51 | return '#' + format(r & 0xff, '02x') + format(g & 0xff, '02x') + format(b & 0xff, '02x') 52 | 53 | 54 | class ColorPalette: 55 | def __init__(self, 56 | mode=MODE_GRAYSCALE, 57 | colors=(BLACK, DARK_GRAY, GRAY, WHITE), 58 | compat_colors=(DARK_GRAY_COMPAT, GRAY_COMPAT)): 59 | if mode not in [MODE_GRAYSCALE, MODE_RGB]: 60 | raise ValueError('mode must be MODE_GRAYSCALE or MODE_RGB') 61 | if len(colors) != 4: 62 | raise ValueError('colors must have 4 color values (black, darkgray, gray, white)') 63 | self.mode = mode 64 | self.black = colors[0] 65 | self.darkgray = colors[1] 66 | self.gray = colors[2] 67 | self.white = colors[3] 68 | if mode == MODE_GRAYSCALE: 69 | self.transparent = TRANSPARENT 70 | else: 71 | self.transparent = RGB_TRANSPARENT 72 | self.darkgray_compat = compat_colors[0] 73 | self.gray_compat = compat_colors[1] 74 | 75 | 76 | DEFAULT_COLORPALETTE = \ 77 | ColorPalette(MODE_GRAYSCALE, 78 | (BLACK, DARK_GRAY, GRAY, WHITE), 79 | (DARK_GRAY_COMPAT, GRAY_COMPAT)) 80 | DEFAULT_RGB_COLORPALETTE = \ 81 | ColorPalette(MODE_RGB, 82 | (RGB_BLACK, RGB_DARK_GRAY, RGB_GRAY, RGB_WHITE), 83 | (RGB_DARK_GRAY_COMPAT, RGB_GRAY_COMPAT)) 84 | -------------------------------------------------------------------------------- /supernotelib/converter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Converter classes.""" 16 | 17 | import base64 18 | import json 19 | import potrace 20 | import svgwrite 21 | 22 | from enum import Enum, auto 23 | from io import BytesIO 24 | 25 | from PIL import Image 26 | 27 | from svglib.svglib import svg2rlg 28 | from reportlab.lib.pagesizes import A4, portrait, landscape 29 | from reportlab.graphics import renderPDF 30 | from reportlab.pdfgen import canvas 31 | 32 | from . import color 33 | from . import decoder as Decoder 34 | from . import exceptions 35 | from . import fileformat 36 | from . import utils 37 | 38 | 39 | class VisibilityOverlay(Enum): 40 | DEFAULT = auto() 41 | VISIBLE = auto() 42 | INVISIBLE = auto() 43 | 44 | 45 | def build_visibility_overlay( 46 | background=VisibilityOverlay.DEFAULT, 47 | main=VisibilityOverlay.DEFAULT, 48 | layer1=VisibilityOverlay.DEFAULT, 49 | layer2=VisibilityOverlay.DEFAULT, 50 | layer3=VisibilityOverlay.DEFAULT): 51 | return { 52 | 'BGLAYER': background, 53 | 'MAINLAYER': main, 54 | 'LAYER1': layer1, 55 | 'LAYER2': layer2, 56 | 'LAYER3': layer3, 57 | } 58 | 59 | 60 | class ImageConverter: 61 | SPECIAL_WHITE_STYLE_BLOCK_SIZE = 0x140e 62 | 63 | def __init__(self, notebook, palette=None): 64 | self.note = notebook 65 | self.palette = palette 66 | 67 | def convert(self, page_number, visibility_overlay=None): 68 | """Returns an image of the given page. 69 | 70 | Parameters 71 | ---------- 72 | page_number : int 73 | page number to convert 74 | 75 | Returns 76 | ------- 77 | PIL.Image.Image 78 | an image object 79 | """ 80 | page = self.note.get_page(page_number) 81 | if page.is_layer_supported(): 82 | highres_grayscale = self.note.supports_highres_grayscale() 83 | converted_img = self._convert_layered_page(page, self.palette, visibility_overlay, highres_grayscale) 84 | else: 85 | converted_img = self._convert_nonlayered_page(page, self.palette, visibility_overlay) 86 | if visibility_overlay is not None and visibility_overlay.get('BGLAYER') == VisibilityOverlay.INVISIBLE: 87 | converted_img = self._make_transparent(converted_img) 88 | return converted_img 89 | 90 | def _convert_nonlayered_page(self, page, palette=None, visibility_overlay=None, highres_grayscale=False): 91 | binary = page.get_content() 92 | if binary is None: 93 | return Image.new('L', (self.note.get_width(), self.note.get_height()), color=color.TRANSPARENT) 94 | decoder = self.find_decoder(page) 95 | return self._create_image_from_decoder(decoder, binary, palette=palette) 96 | 97 | def _convert_layered_page(self, page, palette=None, visibility_overlay=None, highres_grayscale=False): 98 | default_palette = color.DEFAULT_COLORPALETTE 99 | page = utils.WorkaroundPageWrapper.from_page(page) 100 | imgs = {} 101 | layers = page.get_layers() 102 | for layer in layers: 103 | layer_name = layer.get_name() 104 | binary = layer.get_content() 105 | if binary is None: 106 | imgs[layer_name] = None 107 | continue 108 | binary_size = len(binary) 109 | decoder = self.find_decoder(layer, highres_grayscale) 110 | page_style = page.get_style() 111 | all_blank = (layer_name == 'BGLAYER' and page_style is not None and page_style == 'style_white' and \ 112 | binary_size == self.SPECIAL_WHITE_STYLE_BLOCK_SIZE) 113 | custom_bg = (layer_name == 'BGLAYER' and page_style is not None and page_style.startswith('user_')) 114 | if custom_bg: 115 | decoder = Decoder.PngDecoder() 116 | horizontal = page.get_orientation() == fileformat.Page.ORIENTATION_HORIZONTAL 117 | plt = default_palette if layer_name == 'BGLAYER' else palette 118 | img = self._create_image_from_decoder(decoder, binary, palette=plt, blank_hint=all_blank, horizontal=horizontal) 119 | imgs[layer_name] = img 120 | return self._flatten_layers(page, imgs, visibility_overlay) 121 | 122 | def _flatten_layers(self, page, imgs, visibility_overlay=None): 123 | """flatten all layers if any""" 124 | def flatten(fg, bg): 125 | mask = fg.copy().convert('L') 126 | mask = mask.point(lambda x: 0 if x == color.TRANSPARENT else 1, mode='1') 127 | return Image.composite(fg, bg, mask) 128 | horizontal = page.get_orientation() == fileformat.Page.ORIENTATION_HORIZONTAL 129 | page_width, page_height = (self.note.get_width(), self.note.get_height()) 130 | if horizontal: 131 | page_height, page_width = (page_width, page_height) 132 | flatten_img = Image.new('RGB', (page_width, page_height), color=color.RGB_TRANSPARENT) 133 | visibility = self._get_layer_visibility(page) 134 | layer_order = page.get_layer_order() 135 | for name in reversed(layer_order): 136 | is_visible = visibility.get(name) 137 | if visibility_overlay is not None: 138 | overlay = visibility_overlay.get(name) 139 | if overlay == VisibilityOverlay.INVISIBLE or (overlay == VisibilityOverlay.DEFAULT and not is_visible): 140 | continue 141 | else: 142 | if not is_visible: 143 | continue 144 | img_layer = imgs.get(name) 145 | if img_layer is not None: 146 | if name == 'BGLAYER': 147 | # convert transparent to white for custom template 148 | img_layer = self._whiten_transparent(img_layer) 149 | flatten_img = flatten(img_layer, flatten_img) 150 | return flatten_img 151 | 152 | def _whiten_transparent(self, img): 153 | img = img.convert('RGBA') 154 | newImg = Image.new('RGBA', img.size, color.RGB_WHITE) 155 | newImg.paste(img, mask=img) 156 | return newImg 157 | 158 | def _make_transparent(self, img): 159 | transparent_img = Image.new('RGBA', img.size, (255, 255, 255, 0)) 160 | mask = img.copy().convert('L') 161 | mask = mask.point(lambda x: 1 if x == color.TRANSPARENT else 0, mode='1') 162 | img = img.convert('RGBA') 163 | return Image.composite(transparent_img, img, mask) 164 | 165 | def _create_image_from_decoder(self, decoder, binary, palette=None, blank_hint=False, horizontal=False): 166 | page_width = self.note.get_width() 167 | page_height = self.note.get_height() 168 | bitmap, size, bpp = decoder.decode(binary, page_width, page_height, palette=palette, all_blank=blank_hint, horizontal=horizontal) 169 | if bpp == 32: 170 | img = Image.frombytes('RGBA', size, bitmap) 171 | elif bpp == 24: 172 | img = Image.frombytes('RGB', size, bitmap) 173 | elif bpp == 16 and isinstance(decoder, Decoder.PngDecoder): 174 | img = Image.frombytes('LA', size, bitmap) 175 | elif bpp == 16: 176 | img = Image.frombytes('I;16', size, bitmap) 177 | else: 178 | img = Image.frombytes('L', size, bitmap) 179 | return img 180 | 181 | def _get_layer_visibility(self, page): 182 | visibility = {} 183 | info = page.get_layer_info() 184 | if info is None: 185 | # pass to the process of getting visibility for mark file 186 | return self._get_mark_layer_visibility(page) 187 | info_array = json.loads(info) 188 | for layer in info_array: 189 | is_bg_layer = layer.get('isBackgroundLayer') 190 | layer_id = layer.get('layerId') 191 | is_main_layer = (layer_id == 0) and (not is_bg_layer) 192 | is_visible = layer.get('isVisible') 193 | if is_bg_layer: 194 | visibility['BGLAYER'] = is_visible 195 | elif is_main_layer: 196 | visibility['MAINLAYER'] = is_visible 197 | else: 198 | visibility['LAYER' + str(layer_id)] = is_visible 199 | # some old files don't include MAINLAYER info, so we set MAINLAYER visible 200 | if visibility.get('MAINLAYER') is None: 201 | visibility['MAINLAYER'] = True 202 | return visibility 203 | 204 | def _get_mark_layer_visibility(self, page): 205 | visibility = {} 206 | layers = page.get_layers() 207 | for layer in layers: 208 | name = layer.get_name() 209 | visibility[name] = (layer.get_type() == 'MARK') 210 | return visibility 211 | 212 | def find_decoder(self, page, highres_grayscale=False): 213 | """Returns a proper decoder for the given page. 214 | 215 | Parameters 216 | ---------- 217 | page : Page 218 | page object 219 | 220 | Returns 221 | ------- 222 | subclass of BaseDecoder 223 | a decoder 224 | """ 225 | protocol = page.get_protocol() 226 | if protocol == 'SN_ASA_COMPRESS': 227 | return Decoder.FlateDecoder() 228 | elif protocol == 'RATTA_RLE': 229 | if highres_grayscale: 230 | return Decoder.RattaRleX2Decoder() 231 | else: 232 | return Decoder.RattaRleDecoder() 233 | else: 234 | raise exceptions.UnknownDecodeProtocol(f'unknown decode protocol: {protocol}') 235 | 236 | 237 | class SvgConverter: 238 | def __init__(self, notebook, palette=None): 239 | self.note = notebook 240 | self.palette = palette if palette is not None else color.DEFAULT_COLORPALETTE 241 | self.image_converter = ImageConverter(notebook, palette=color.DEFAULT_COLORPALETTE) # use default pallete 242 | 243 | def convert(self, page_number, visibility_overlay=None): 244 | """Returns SVG string of the given page. 245 | 246 | Parameters 247 | ---------- 248 | page_number : int 249 | page number to convert 250 | 251 | Returns 252 | ------- 253 | string 254 | an SVG string 255 | """ 256 | page = self.note.get_page(page_number) 257 | horizontal = page.get_orientation() == fileformat.Page.ORIENTATION_HORIZONTAL 258 | page_width, page_height = (self.note.get_width(), self.note.get_height()) 259 | if horizontal: 260 | page_height, page_width = (page_width, page_height) 261 | dwg = svgwrite.Drawing('dummy.svg', profile='full', size=(page_width, page_height)) 262 | 263 | bg_is_invisible = visibility_overlay is not None and visibility_overlay.get('BGLAYER') == VisibilityOverlay.INVISIBLE 264 | if not bg_is_invisible: 265 | vo_only_bg = build_visibility_overlay( 266 | background=VisibilityOverlay.VISIBLE, 267 | main=VisibilityOverlay.INVISIBLE, 268 | layer1=VisibilityOverlay.INVISIBLE, 269 | layer2=VisibilityOverlay.INVISIBLE, 270 | layer3=VisibilityOverlay.INVISIBLE) 271 | bg_img = self.image_converter.convert(page_number, visibility_overlay=vo_only_bg) 272 | buffer = BytesIO() 273 | bg_img.save(buffer, format='png') 274 | bg_b64str = base64.b64encode(buffer.getvalue()).decode('ascii') 275 | dwg.add(dwg.image('data:image/png;base64,' + bg_b64str, insert=(0, 0), size=(page_width, page_height))) 276 | 277 | vo_except_bg = build_visibility_overlay(background=VisibilityOverlay.INVISIBLE) 278 | img = self.image_converter.convert(page_number, visibility_overlay=vo_except_bg) 279 | 280 | def generate_color_mask(img, c): 281 | mask = img.copy().convert('L') 282 | return mask.point(lambda x: 0 if x == c else 1, mode='1') 283 | 284 | default_palette = color.DEFAULT_COLORPALETTE 285 | default_color_list = [default_palette.black, default_palette.darkgray, default_palette.gray, default_palette.white] 286 | user_color_list = [self.palette.black, self.palette.darkgray, self.palette.gray, self.palette.white] 287 | for i, c in enumerate(default_color_list): 288 | user_color = user_color_list[i] 289 | mask = generate_color_mask(img, c) 290 | # create a bitmap from the array 291 | bmp = potrace.Bitmap(mask) 292 | # trace the bitmap to a path 293 | path = bmp.trace() 294 | # iterate over path curves 295 | if len(path) > 0: 296 | svgpath = dwg.path(fill=color.web_string(user_color, mode=self.palette.mode)) 297 | for curve in path: 298 | start = curve.start_point 299 | svgpath.push("M", start.x, start.y) 300 | for segment in curve: 301 | end = segment.end_point 302 | if segment.is_corner: 303 | c = segment.c 304 | svgpath.push("L", c.x, c.y) 305 | svgpath.push("L", end.x, end.y) 306 | else: 307 | c1 = segment.c1 308 | c2 = segment.c2 309 | svgpath.push("C", c1.x, c1.y, c2.x, c2.y, end.x, end.y) 310 | svgpath.push("Z") 311 | dwg.add(svgpath) 312 | return dwg.tostring() 313 | 314 | 315 | class PdfConverter: 316 | def __init__(self, notebook, palette=None): 317 | self.note = notebook 318 | self.palette = palette 319 | self.pagesize = A4 320 | 321 | def convert(self, page_number, vectorize=False, enable_link=False, enable_keyword=False): 322 | """Returns PDF data of the given page. 323 | 324 | Parameters 325 | ---------- 326 | page_number : int 327 | page number to convert 328 | vectorize : bool 329 | convert handwriting to vector 330 | enable_link : bool 331 | enable page links and web links 332 | enable_keyword : bool 333 | enable page link where keyword has been identified 334 | 335 | Returns 336 | ------- 337 | data : bytes 338 | bytes of PDF data 339 | """ 340 | if vectorize: 341 | converter = SvgConverter(self.note, self.palette) 342 | renderer_class = PdfConverter.SvgPageRenderer 343 | else: 344 | converter = ImageConverter(self.note, self.palette) 345 | renderer_class = PdfConverter.ImgPageRenderer 346 | imglist = self._create_image_list(converter, page_number) 347 | pdf_data = BytesIO() 348 | self._create_pdf(pdf_data, imglist, renderer_class, enable_link, enable_keyword) 349 | return pdf_data.getvalue() 350 | 351 | def _create_image_list(self, converter, page_number): 352 | imglist = [] 353 | if page_number < 0: 354 | # convert all pages 355 | total = self.note.get_total_pages() 356 | for i in range(total): 357 | img = converter.convert(i) 358 | imglist.append(img) 359 | else: 360 | img = converter.convert(page_number) 361 | imglist.append(img) 362 | return imglist 363 | 364 | def _create_pdf(self, buf, imglist, renderer_class, enable_link, enable_keyword): 365 | c = canvas.Canvas(buf, pagesize=self.pagesize) 366 | keywords = self.note.get_keywords() 367 | for n, img in enumerate(imglist): 368 | page = self.note.get_page(n) 369 | pageid = page.get_pageid() 370 | horizontal = page.get_orientation() == fileformat.Page.ORIENTATION_HORIZONTAL 371 | pagesize = landscape(self.pagesize) if horizontal else portrait(self.pagesize) 372 | c.setPageSize(pagesize) 373 | renderer = renderer_class(img, pagesize) 374 | renderer.draw(c) 375 | if enable_keyword: 376 | found = [] 377 | for keyword in keywords: 378 | if keyword.get_page_number() == n: 379 | found.append(keyword) 380 | for i in found: 381 | try: 382 | c.bookmarkPage(pageid) 383 | scaled_rect = self._calc_link_rect(i.get_rect(), renderer.get_scale()) 384 | c.textAnnotation(i.get_keyword(),scaled_rect) 385 | except: 386 | continue 387 | if enable_link: 388 | pageid = page.get_pageid() 389 | if pageid is not None: 390 | c.bookmarkPage(pageid) 391 | self._add_links(c, n, renderer.get_scale()) 392 | c.showPage() 393 | c.save() 394 | 395 | def _add_links(self, cvs, page_number, scale): 396 | links = self.note.get_links() 397 | for link in links: 398 | if link.get_page_number() != page_number: 399 | continue 400 | if link.get_inout() == fileformat.Link.DIRECTION_IN: 401 | # ignore income link 402 | continue 403 | link_type = link.get_type() 404 | is_internal_link = link.get_fileid() == self.note.get_fileid() 405 | if link_type == fileformat.Link.TYPE_PAGE_LINK and is_internal_link: 406 | tag = link.get_pageid() 407 | scaled_rect = self._calc_link_rect(link.get_rect(), scale) 408 | cvs.linkAbsolute("Link", tag, scaled_rect) 409 | elif link_type == fileformat.Link.TYPE_WEB_LINK: 410 | encoded_url = link.get_filepath() 411 | url = base64.b64decode(encoded_url).decode() 412 | scaled_rect = self._calc_link_rect(link.get_rect(), scale) 413 | cvs.linkURL(url, scaled_rect) 414 | 415 | def _calc_link_rect(self, rect, scale): 416 | (left, top, right, bottom) = rect 417 | (scale_x, scale_y) = scale 418 | (w, h) = self.pagesize 419 | return (left * scale_x, h - top * scale_y, right * scale_x, h - bottom * scale_y) 420 | 421 | class SvgPageRenderer: 422 | def __init__(self, svg, pagesize): 423 | self.svg = svg 424 | self.pagesize = pagesize 425 | self.drawing = svg2rlg(BytesIO(bytes(svg, 'ascii'))) 426 | (w, h) = pagesize 427 | (self.scale_x, self.scale_y) = (w / self.drawing.width, h / self.drawing.height) 428 | self.drawing.scale(self.scale_x, self.scale_y) 429 | 430 | def get_scale(self): 431 | return (self.scale_x, self.scale_y) 432 | 433 | def draw(self, cvs): 434 | renderPDF.draw(self.drawing, cvs, 0, 0) 435 | 436 | class ImgPageRenderer: 437 | def __init__(self, img, pagesize): 438 | self.img = img 439 | self.pagesize = pagesize 440 | 441 | def get_scale(self): 442 | (w, h) = self.pagesize 443 | return (w / self.img.width, h / self.img.height) 444 | 445 | def draw(self, cvs): 446 | (w, h) = self.pagesize 447 | cvs.drawInlineImage(self.img, 0, 0, width=w, height=h) 448 | 449 | 450 | class TextConverter: 451 | def __init__(self, notebook, palette=None): 452 | self.note = notebook 453 | self.palette = palette 454 | 455 | def convert(self, page_number): 456 | """Returns text of the given page if available. 457 | 458 | Parameters 459 | ---------- 460 | page_number : int 461 | page number to convert 462 | 463 | Returns 464 | ------- 465 | string 466 | a recognized text if available, otherwise None 467 | """ 468 | if not self.note.is_realtime_recognition(): 469 | return None 470 | page = self.note.get_page(page_number) 471 | if page.get_recogn_status() != fileformat.Page.RECOGNSTATUS_DONE: 472 | return None 473 | binary = page.get_recogn_text() 474 | decoder = Decoder.TextDecoder() 475 | text_list = decoder.decode(binary) 476 | if text_list is None: 477 | return None 478 | return ' '.join(text_list) 479 | 480 | -------------------------------------------------------------------------------- /supernotelib/decoder.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Decoder classes.""" 16 | 17 | import base64 18 | import json 19 | import numpy as np 20 | import png 21 | import queue 22 | import zlib 23 | 24 | from . import color 25 | from . import exceptions 26 | 27 | 28 | class BaseDecoder: 29 | """Abstract decoder class.""" 30 | def decode(self, data, palette=None, all_blank=False, horizontal=False): 31 | raise NotImplementedError('subclasses must implement decode method') 32 | 33 | 34 | class FlateDecoder(BaseDecoder): 35 | """Decoder for SN_ASA_COMPRESS protocol.""" 36 | COLORCODE_BLACK = 0x0000 37 | COLORCODE_BACKGROUND = 0xffff 38 | COLORCODE_DARK_GRAY = 0x2104 39 | COLORCODE_GRAY = 0xe1e2 40 | 41 | INTERNAL_PAGE_HEIGHT = 1888 42 | INTERNAL_PAGE_WIDTH = 1404 43 | 44 | def decode(self, data, page_width, page_height, palette=None, all_blank=False, horizontal=False): 45 | """Uncompress bitmap data. 46 | 47 | Parameters 48 | ---------- 49 | data : bytes 50 | compressed bitmap data 51 | page_width : int 52 | page width 53 | page_height : int 54 | page height 55 | 56 | Returns 57 | ------- 58 | bytes 59 | uncompressed bitmap data 60 | tuple(int, int) 61 | bitmap size (width, height) 62 | int 63 | bit per pixel 64 | """ 65 | uncompressed = zlib.decompress(data) 66 | bitmap = np.frombuffer(uncompressed, dtype=np.uint16) 67 | bitmap = np.reshape(bitmap, (self.INTERNAL_PAGE_WIDTH, self.INTERNAL_PAGE_HEIGHT)) 68 | bitmap = np.rot90(bitmap, -1) # rotate 90 degrees clockwise 69 | bitmap = np.delete(bitmap, slice(-16, None), axis=0) # delete bottom 16 lines 70 | 71 | # change colors 72 | if palette is None: 73 | palette = color.DEFAULT_COLORPALETTE 74 | if palette.mode == color.MODE_RGB: 75 | bit_per_pixel = 32 76 | bitmap = bitmap.astype('>u4') 77 | alpha = 0xff 78 | bitmap[bitmap == self.COLORCODE_BLACK] = (palette.black << 8) | alpha 79 | bitmap[bitmap == self.COLORCODE_DARK_GRAY] = (palette.darkgray << 8) | alpha 80 | bitmap[bitmap == self.COLORCODE_GRAY] = (palette.gray << 8) | alpha 81 | bitmap[bitmap == self.COLORCODE_BACKGROUND] = (palette.white << 8) | alpha 82 | else: 83 | bit_per_pixel = 8 84 | bitmap[bitmap == self.COLORCODE_BLACK] = palette.black 85 | bitmap[bitmap == self.COLORCODE_DARK_GRAY] = palette.darkgray 86 | bitmap[bitmap == self.COLORCODE_GRAY] = palette.gray 87 | bitmap[bitmap == self.COLORCODE_BACKGROUND] = palette.white 88 | bitmap = bitmap.astype(np.uint8) 89 | return bitmap.tobytes(), (page_width, page_height), bit_per_pixel 90 | 91 | 92 | class RattaRleDecoder(BaseDecoder): 93 | """Decoder for RATTA_RLE protocol.""" 94 | COLORCODE_BLACK = 0x61 95 | COLORCODE_BACKGROUND = 0x62 96 | COLORCODE_DARK_GRAY = 0x63 97 | COLORCODE_GRAY = 0x64 98 | COLORCODE_WHITE = 0x65 99 | COLORCODE_MARKER_BLACK = 0x66 100 | COLORCODE_MARKER_DARK_GRAY = 0x67 101 | COLORCODE_MARKER_GRAY = 0x68 102 | 103 | SPECIAL_LENGTH_MARKER = 0xff 104 | SPECIAL_LENGTH = 0x4000 105 | SPECIAL_LENGTH_FOR_BLANK = 0x400 106 | 107 | def decode(self, data, page_width, page_height, palette=None, all_blank=False, horizontal=False): 108 | """Uncompress bitmap data. 109 | 110 | Parameters 111 | ---------- 112 | data : bytes 113 | compressed bitmap data 114 | page_width : int 115 | page width 116 | page_height : int 117 | page height 118 | 119 | Returns 120 | ------- 121 | bytes 122 | uncompressed bitmap data 123 | tuple(int, int) 124 | bitmap size (width, height) 125 | int 126 | bit per pixel 127 | """ 128 | if palette is None: 129 | palette = color.DEFAULT_COLORPALETTE 130 | 131 | if palette.mode == color.MODE_RGB: 132 | bit_per_pixel = 24 133 | else: 134 | bit_per_pixel = 8 135 | 136 | colormap = self._create_colormap(palette) 137 | 138 | if horizontal: 139 | page_height, page_width = (page_width, page_height) # swap width and height 140 | 141 | expected_length = page_height * page_width * int(bit_per_pixel / 8) 142 | 143 | uncompressed = bytearray() 144 | bin = iter(data) 145 | try: 146 | holder = () 147 | waiting = queue.Queue() 148 | while True: 149 | colorcode = next(bin) 150 | length = next(bin) 151 | data_pushed = False 152 | 153 | if len(holder) > 0: 154 | (prev_colorcode, prev_length) = holder 155 | holder = () 156 | if colorcode == prev_colorcode: 157 | length = 1 + length + (((prev_length & 0x7f) + 1) << 7) 158 | waiting.put((colorcode, length)) 159 | data_pushed = True 160 | else: 161 | prev_length = ((prev_length & 0x7f) + 1) << 7 162 | waiting.put((prev_colorcode, prev_length)) 163 | 164 | if not data_pushed: 165 | if length == self.SPECIAL_LENGTH_MARKER: 166 | if all_blank: 167 | length = self.SPECIAL_LENGTH_FOR_BLANK 168 | else: 169 | length = self.SPECIAL_LENGTH 170 | waiting.put((colorcode, length)) 171 | data_pushed = True 172 | elif length & 0x80 != 0: 173 | holder = (colorcode, length) 174 | # holded data are processed at next loop 175 | else: 176 | length += 1 177 | waiting.put((colorcode, length)) 178 | data_pushed = True 179 | 180 | while not waiting.empty(): 181 | (colorcode, length) = waiting.get() 182 | uncompressed += self._create_color_bytearray(palette.mode, colormap, colorcode, length) 183 | except StopIteration: 184 | if len(holder) > 0: 185 | (colorcode, length) = holder 186 | length = self._adjust_tail_length(length, len(uncompressed), expected_length) 187 | if length > 0: 188 | uncompressed += self._create_color_bytearray(palette.mode, colormap, colorcode, length) 189 | 190 | if len(uncompressed) != expected_length: 191 | raise exceptions.DecoderException(f'uncompressed bitmap length = {len(uncompressed)}, expected = {expected_length}') 192 | 193 | return bytes(uncompressed), (page_width, page_height), bit_per_pixel 194 | 195 | def _create_colormap(self, palette): 196 | colormap = { 197 | self.COLORCODE_BLACK: palette.black, 198 | self.COLORCODE_BACKGROUND: palette.transparent, 199 | self.COLORCODE_DARK_GRAY: palette.darkgray, 200 | self.COLORCODE_GRAY: palette.gray, 201 | self.COLORCODE_WHITE: palette.white, 202 | self.COLORCODE_MARKER_BLACK: palette.black, 203 | self.COLORCODE_MARKER_DARK_GRAY: palette.darkgray, 204 | self.COLORCODE_MARKER_GRAY: palette.gray, 205 | } 206 | return colormap 207 | 208 | def _create_color_bytearray(self, mode, colormap, color_code, length): 209 | if mode == color.MODE_RGB: 210 | c = colormap.get(color_code) 211 | r, g, b = color.get_rgb(c) 212 | return bytearray((r, g, b,)) * length 213 | else: 214 | c = colormap.get(color_code) 215 | return bytearray((c,)) * length 216 | 217 | def _adjust_tail_length(self, tail_length, current_length, total_length): 218 | gap = total_length - current_length 219 | for i in reversed(range(8)): 220 | l = ((tail_length & 0x7f) + 1) << i 221 | if l <= gap: 222 | return l 223 | return 0 224 | 225 | 226 | class RattaRleX2Decoder(RattaRleDecoder): 227 | """Decoder for RATTA_RLE protocol of X2-series.""" 228 | # 4 color codes were changed from X-series 229 | COLORCODE_DARK_GRAY = 0x9D 230 | COLORCODE_GRAY = 0xC9 231 | COLORCODE_MARKER_DARK_GRAY = 0x9E 232 | COLORCODE_MARKER_GRAY = 0xCA 233 | # color codes for X-series compatibility 234 | COLORCODE_DARK_GRAY_COMPAT = 0x63 235 | COLORCODE_GRAY_COMPAT = 0x64 236 | 237 | def _create_colormap(self, palette): 238 | colormap = { 239 | self.COLORCODE_BLACK: palette.black, 240 | self.COLORCODE_BACKGROUND: palette.transparent, 241 | self.COLORCODE_DARK_GRAY: palette.darkgray, 242 | self.COLORCODE_GRAY: palette.gray, 243 | self.COLORCODE_WHITE: palette.white, 244 | self.COLORCODE_MARKER_BLACK: palette.black, 245 | self.COLORCODE_MARKER_DARK_GRAY: palette.darkgray, 246 | self.COLORCODE_MARKER_GRAY: palette.gray, 247 | self.COLORCODE_DARK_GRAY_COMPAT: palette.darkgray_compat, 248 | self.COLORCODE_GRAY_COMPAT: palette.gray_compat, 249 | } 250 | return colormap 251 | 252 | def _create_color_bytearray(self, mode, colormap, color_code, length): 253 | if mode == color.MODE_RGB: 254 | c = colormap.get(color_code) 255 | if c is not None: 256 | r, g, b = color.get_rgb(c) 257 | else: # if the color_code is not included in colormap, use the value as color directly 258 | r, g, b = (color_code, color_code, color_code) 259 | return bytearray((r, g, b,)) * length 260 | else: 261 | c = colormap.get(color_code) 262 | if c is None: 263 | c = color_code 264 | return bytearray((c,)) * length 265 | 266 | 267 | class PngDecoder(BaseDecoder): 268 | """Decoder for PNG.""" 269 | 270 | def decode(self, data, page_width, page_height, palette=None, all_blank=False, horizontal=False): 271 | """Uncompress bitmap data. 272 | 273 | Parameters 274 | ---------- 275 | data : bytes 276 | png data 277 | page_width : int 278 | page width 279 | page_height : int 280 | page height 281 | 282 | Returns 283 | ------- 284 | bytes 285 | uncompressed bitmap data 286 | tuple(int, int) 287 | bitmap size (width, height) 288 | int 289 | bit per pixel 290 | """ 291 | r = png.Reader(bytes=data) 292 | (width, height, rows, info) = r.asRGBA() 293 | if width != page_width or height != page_height: 294 | raise exceptions.DecoderException(f'invalid size = ({width}, {height}), expected = ({page_width}, {page_height})') 295 | values = [x for row in rows for x in row] # flatten rows 296 | depth = info['bitdepth'] 297 | greyscale = info['greyscale'] 298 | alpha = info['alpha'] 299 | ch = 1 if greyscale else 3 300 | if alpha: 301 | ch = ch + 1 302 | bit_per_pixel = depth * ch 303 | return bytes(values), (page_width, page_height), bit_per_pixel 304 | 305 | 306 | class TextDecoder(BaseDecoder): 307 | """Decoder for text.""" 308 | 309 | def decode(self, data, palette=None, all_blank=False, horizontal=False): 310 | """Extract text from a realtime recognition data. 311 | 312 | Parameters 313 | ---------- 314 | data : bytes 315 | recognition text data (base64 encoded) 316 | 317 | Returns 318 | ------- 319 | list of string 320 | list of recognized text 321 | """ 322 | if data is None: 323 | return None 324 | recogn_json = base64.b64decode(data).decode('utf-8') 325 | recogn = json.loads(recogn_json) 326 | elements = recogn.get('elements') 327 | return list(map(lambda e : e.get('label'), filter(lambda e : e.get('type') == 'Text', elements))) 328 | -------------------------------------------------------------------------------- /supernotelib/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Library-specific exception classes.""" 16 | 17 | class SupernoteLibException(Exception): 18 | """Base class of all Supernote library exceptions.""" 19 | 20 | class ParserException(SupernoteLibException): 21 | """Base class of parser exceptions.""" 22 | 23 | class UnsupportedFileFormat(ParserException): 24 | """Raised if file format is unsupported.""" 25 | 26 | class DecoderException(SupernoteLibException): 27 | """Base class of decoder exceptions.""" 28 | 29 | class UnknownDecodeProtocol(DecoderException): 30 | """Raised if decode protocol is unknown.""" 31 | 32 | class ManipulatorException(SupernoteLibException): 33 | """Base class of manipulator exceptions.""" 34 | 35 | class GeneratedFileValidationException(ManipulatorException): 36 | """Raised if generated file is invalid.""" 37 | -------------------------------------------------------------------------------- /supernotelib/fileformat.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Classes for Suernote file format.""" 16 | 17 | import json 18 | 19 | from . import exceptions 20 | 21 | 22 | #### Constants 23 | 24 | PAGE_HEIGHT = 1872 25 | PAGE_WIDTH = 1404 26 | A5X2_PAGE_HEIGHT = 2560 27 | A5X2_PAGE_WIDTH = 1920 28 | 29 | 30 | ADDRESS_SIZE = 4 31 | LENGTH_FIELD_SIZE = 4 32 | 33 | KEY_TYPE = '__type__' 34 | KEY_SIGNATURE = '__signature__' 35 | KEY_HEADER = '__header__' 36 | KEY_FOOTER = '__footer__' 37 | KEY_PAGES = '__pages__' 38 | KEY_LAYERS = '__layers__' 39 | KEY_KEYWORDS = '__keywords__' 40 | KEY_TITLES = '__titles__' 41 | KEY_LINKS = '__links__' 42 | 43 | 44 | class SupernoteMetadata: 45 | """Represents Supernote file structure.""" 46 | def __init__(self): 47 | self.__note = { 48 | KEY_TYPE: None, 49 | KEY_SIGNATURE: None, 50 | KEY_HEADER: None, 51 | KEY_FOOTER: None, 52 | KEY_PAGES: None, 53 | } 54 | 55 | @property 56 | def type(self): 57 | return self.__note[KEY_TYPE] 58 | 59 | @type.setter 60 | def type(self, value): 61 | self.__note[KEY_TYPE] = value 62 | 63 | @property 64 | def signature(self): 65 | return self.__note[KEY_SIGNATURE] 66 | 67 | @signature.setter 68 | def signature(self, value): 69 | self.__note[KEY_SIGNATURE] = value 70 | 71 | @property 72 | def header(self): 73 | return self.__note[KEY_HEADER] 74 | 75 | @header.setter 76 | def header(self, value): 77 | self.__note[KEY_HEADER] = value 78 | 79 | @property 80 | def footer(self): 81 | return self.__note[KEY_FOOTER] 82 | 83 | @footer.setter 84 | def footer(self, value): 85 | self.__note[KEY_FOOTER] = value 86 | 87 | @property 88 | def pages(self): 89 | return self.__note[KEY_PAGES] 90 | 91 | @pages.setter 92 | def pages(self, value): 93 | self.__note[KEY_PAGES] = value 94 | 95 | def get_total_pages(self): 96 | """Returns total page number. 97 | 98 | Returns 99 | ------- 100 | int 101 | total page number 102 | """ 103 | return len(self.__note[KEY_PAGES]) 104 | 105 | def is_layer_supported(self, page_number): 106 | """Returns true if the page supports layer. 107 | 108 | Parameters 109 | ---------- 110 | page_number : int 111 | page number to check 112 | 113 | Returns 114 | ------- 115 | bool 116 | true if the page supports layer. 117 | """ 118 | if page_number < 0 or page_number >= self.get_total_pages(): 119 | raise IndexError(f'page number out of range: {page_number}') 120 | return self.__note[KEY_PAGES][page_number].get(KEY_LAYERS) is not None 121 | 122 | def to_json(self, indent=None): 123 | """Returns file structure as JSON format string. 124 | 125 | Parameters 126 | ---------- 127 | indent : int 128 | optional indent level 129 | 130 | Returns 131 | ------- 132 | str 133 | JSON format string 134 | """ 135 | return json.dumps(self.__note, indent=indent, ensure_ascii=False) 136 | 137 | 138 | class Notebook: 139 | def __init__(self, metadata): 140 | self.metadata = metadata 141 | self.page_width = PAGE_WIDTH 142 | self.page_height = PAGE_HEIGHT 143 | if (self.metadata.header.get('APPLY_EQUIPMENT') == 'N5'): 144 | self.page_width = A5X2_PAGE_WIDTH 145 | self.page_height = A5X2_PAGE_HEIGHT 146 | self.type = metadata.type 147 | self.signature = metadata.signature 148 | self.cover = Cover() 149 | self.keywords = [] 150 | has_keywords = metadata.footer.get(KEY_KEYWORDS) is not None 151 | if has_keywords: 152 | for k in metadata.footer.get(KEY_KEYWORDS): 153 | self.keywords.append(Keyword(k)) 154 | self.titles = [] 155 | has_titles = metadata.footer.get(KEY_TITLES) is not None 156 | if has_titles: 157 | for t in metadata.footer.get(KEY_TITLES): 158 | self.titles.append(Title(t)) 159 | self.links = [] 160 | has_links = metadata.footer.get(KEY_LINKS) is not None 161 | if has_links: 162 | for l in metadata.footer.get(KEY_LINKS): 163 | self.links.append(Link(l)) 164 | self.pages = [] 165 | total = metadata.get_total_pages() 166 | for i in range(total): 167 | self.pages.append(Page(metadata.pages[i])) 168 | 169 | def get_metadata(self): 170 | return self.metadata 171 | 172 | def get_width(self): 173 | return self.page_width 174 | 175 | def get_height(self): 176 | return self.page_height 177 | 178 | def get_type(self): 179 | return self.type 180 | 181 | def get_signature(self): 182 | return self.signature 183 | 184 | def get_total_pages(self): 185 | return len(self.pages) 186 | 187 | def get_page(self, number): 188 | if number < 0 or number >= len(self.pages): 189 | raise IndexError(f'page number out of range: {number}') 190 | return self.pages[number] 191 | 192 | def get_cover(self): 193 | return self.cover 194 | 195 | def get_keywords(self): 196 | return self.keywords 197 | 198 | def get_titles(self): 199 | return self.titles 200 | 201 | def get_links(self): 202 | return self.links 203 | 204 | def get_fileid(self): 205 | return self.metadata.header.get('FILE_ID') 206 | 207 | def is_realtime_recognition(self): 208 | return self.metadata.header.get('FILE_RECOGN_TYPE') == '1' 209 | 210 | def supports_highres_grayscale(self): 211 | return int(self.signature[-8:]) >= 20230015 212 | 213 | class Cover: 214 | def __init__(self): 215 | self.content = None 216 | 217 | def set_content(self, content): 218 | self.content = content 219 | 220 | def get_content(self): 221 | return self.content 222 | 223 | class Keyword: 224 | def __init__(self, keyword_info): 225 | self.metadata = keyword_info 226 | self.content = None 227 | self.page_number = int(self.metadata['KEYWORDPAGE']) - 1 228 | 229 | def set_content(self, content): 230 | self.content = content 231 | 232 | def get_content(self): 233 | return self.content 234 | 235 | def get_page_number(self): 236 | return self.page_number 237 | 238 | def get_position_string(self): 239 | (left, top, width, height) = self.metadata['KEYWORDRECTORI'].split(',') 240 | return f'{int(top):04d}' 241 | 242 | def get_keyword(self): 243 | return None if self.metadata['KEYWORD'] is None else str(self.metadata['KEYWORD']) 244 | 245 | def get_rect(self): 246 | (left, top, width, height) = self.metadata['KEYWORDRECT'].split(',') 247 | return (int(left), int(top), int(left) + int(width), int(top) + int(height)) 248 | 249 | class Title: 250 | def __init__(self, title_info): 251 | self.metadata = title_info 252 | self.content = None 253 | self.page_number = 0 254 | 255 | def set_content(self, content): 256 | self.content = content 257 | 258 | def get_content(self): 259 | return self.content 260 | 261 | def set_page_number(self, page_number): 262 | self.page_number = page_number 263 | 264 | def get_page_number(self): 265 | return self.page_number 266 | 267 | def get_position_string(self): 268 | (left, top, width, height) = self.metadata['TITLERECTORI'].split(',') 269 | return f'{int(top):04d}{int(left):04d}' 270 | 271 | class Link: 272 | TYPE_PAGE_LINK = 0 273 | TYPE_FILE_LINK = 1 274 | TYPE_WEB_LINK = 4 275 | 276 | DIRECTION_OUT = 0 277 | DIRECTION_IN = 1 278 | 279 | def __init__(self, link_info): 280 | self.metadata = link_info 281 | self.content = None 282 | self.page_number = 0 283 | 284 | def set_content(self, content): 285 | self.content = content 286 | 287 | def get_content(self): 288 | return self.content 289 | 290 | def set_page_number(self, page_number): 291 | self.page_number = page_number 292 | 293 | def get_page_number(self): 294 | return self.page_number 295 | 296 | def get_type(self): 297 | return int(self.metadata['LINKTYPE']) 298 | 299 | def get_inout(self): 300 | return int(self.metadata['LINKINOUT']) 301 | 302 | def get_position_string(self): 303 | (left, top, width, height) = self.metadata['LINKRECT'].split(',') 304 | return f'{int(top):04d}{int(left):04d}{int(height):04d}{int(width):04d}' 305 | 306 | def get_rect(self): 307 | (left, top, width, height) = self.metadata['LINKRECT'].split(',') 308 | return (int(left), int(top), int(left) + int(width), int(top) + int(height)) 309 | 310 | def get_timestamp(self): 311 | return self.metadata['LINKTIMESTAMP'] 312 | 313 | def get_filepath(self): 314 | return self.metadata['LINKFILE'] # Base64-encoded file path or URL 315 | 316 | def get_fileid(self): 317 | return None if self.metadata['LINKFILEID'] == 'none' else self.metadata['LINKFILEID'] 318 | 319 | def get_pageid(self): 320 | return None if self.metadata['PAGEID'] == 'none' else self.metadata['PAGEID'] 321 | 322 | class Page: 323 | RECOGNSTATUS_NONE = 0 324 | RECOGNSTATUS_DONE = 1 325 | RECOGNSTATUS_RUNNING = 2 326 | 327 | ORIENTATION_VERTICAL = "1000" 328 | ORIENTATION_HORIZONTAL = "1090" 329 | 330 | def __init__(self, page_info): 331 | self.metadata = page_info 332 | self.content = None 333 | self.totalpath = None 334 | self.recogn_file = None 335 | self.recogn_text = None 336 | self.layers = [] 337 | layer_supported = page_info.get(KEY_LAYERS) is not None 338 | if layer_supported: 339 | for i in range(5): 340 | self.layers.append(Layer(self.metadata[KEY_LAYERS][i])) 341 | 342 | def set_content(self, content): 343 | self.content = content 344 | 345 | def get_content(self): 346 | return self.content 347 | 348 | def is_layer_supported(self): 349 | """Returns True if this page supports layer. 350 | 351 | Returns 352 | ------- 353 | bool 354 | True if this page supports layer. 355 | """ 356 | return self.metadata.get(KEY_LAYERS) is not None 357 | 358 | def get_layers(self): 359 | return self.layers 360 | 361 | def get_layer(self, number): 362 | if number < 0 or number >= len(self.layers): 363 | raise IndexError(f'layer number out of range: {number}') 364 | return self.layers[number] 365 | 366 | def get_protocol(self): 367 | if self.is_layer_supported(): 368 | # currently MAINLAYER is only supported 369 | protocol = self.get_layer(0).metadata.get('LAYERPROTOCOL') 370 | else: 371 | protocol = self.metadata.get('PROTOCOL') 372 | return protocol 373 | 374 | def get_style(self): 375 | return self.metadata.get('PAGESTYLE') 376 | 377 | def get_style_hash(self): 378 | hashcode = self.metadata.get('PAGESTYLEMD5') 379 | if hashcode == '0': 380 | return '' 381 | return hashcode 382 | 383 | def get_layer_info(self): 384 | info = self.metadata.get('LAYERINFO') 385 | if info is None or info == 'none': 386 | return None 387 | return info.replace('#', ':') 388 | 389 | def get_layer_order(self): 390 | seq = self.metadata.get('LAYERSEQ') 391 | if seq is None: 392 | return [] 393 | order = seq.split(',') 394 | return order 395 | 396 | def set_totalpath(self, totalpath): 397 | self.totalpath = totalpath 398 | 399 | def get_totalpath(self): 400 | return self.totalpath 401 | 402 | def get_pageid(self): 403 | return self.metadata.get('PAGEID') 404 | 405 | def get_recogn_status(self): 406 | return int(self.metadata.get('RECOGNSTATUS')) 407 | 408 | def set_recogn_file(self, recogn_file): 409 | self.recogn_file = recogn_file 410 | 411 | def get_recogn_file(self): 412 | return self.recogn_file 413 | 414 | def set_recogn_text(self, recogn_text): 415 | self.recogn_text = recogn_text 416 | 417 | def get_recogn_text(self): 418 | return self.recogn_text 419 | 420 | def get_orientation(self): 421 | return self.metadata.get('ORIENTATION', self.ORIENTATION_VERTICAL) 422 | 423 | class Layer: 424 | def __init__(self, layer_info): 425 | self.metadata = layer_info 426 | self.content = None 427 | 428 | def set_content(self, content): 429 | self.content = content 430 | 431 | def get_content(self): 432 | return self.content 433 | 434 | def get_name(self): 435 | return self.metadata.get('LAYERNAME') 436 | 437 | def get_protocol(self): 438 | return self.metadata.get('LAYERPROTOCOL') 439 | 440 | def get_type(self): 441 | return self.metadata.get('LAYERTYPE') 442 | -------------------------------------------------------------------------------- /supernotelib/manipulator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Note manipulator classes.""" 16 | 17 | import io 18 | import os 19 | import re 20 | 21 | from . import exceptions 22 | from . import fileformat 23 | from . import parser 24 | from . import utils 25 | 26 | class NotebookBuilder: 27 | def __init__(self, offset=0): 28 | self.total_size = offset 29 | self.toc = {} 30 | self.blocks = [] 31 | 32 | def get_total_size(self): 33 | return self.total_size 34 | 35 | def get_block_address(self, label): 36 | if type(self.toc.get(label)) == list: 37 | address = self.toc.get(label)[0] # use first one 38 | else: 39 | address = self.toc.get(label) 40 | return address if address is not None else 0 41 | 42 | def get_duplicate_block_address_list(self, label): 43 | if type(self.toc.get(label)) == list: 44 | return self.toc.get(label) 45 | else: 46 | return [self.toc.get(label)] 47 | 48 | def get_labels(self): 49 | return self.toc.keys() 50 | 51 | def append(self, label, block, skip_block_size=False, allow_duplicate=False): 52 | if not label or block is None: 53 | raise ValueError('empty label or block is not allowed') 54 | label_duplicated = label in self.toc 55 | if label_duplicated and not allow_duplicate: 56 | return False 57 | block_size = len(block) 58 | if not skip_block_size: 59 | self.blocks.append(block_size.to_bytes(fileformat.LENGTH_FIELD_SIZE, 'little')) 60 | self.blocks.append(block) 61 | if label_duplicated: 62 | if type(self.toc[label]) == list: 63 | self.toc[label].append(self.total_size) 64 | else: 65 | self.toc[label] = [self.toc[label], self.total_size] 66 | else: 67 | self.toc.setdefault(label, self.total_size) 68 | self.total_size += block_size 69 | if not skip_block_size: 70 | self.total_size += fileformat.LENGTH_FIELD_SIZE 71 | return True 72 | 73 | def build(self): 74 | return b''.join(self.blocks) 75 | 76 | def dump(self): 77 | print('# NotebookBuilder Dump:') 78 | print(f'# total_size = {self.total_size}') 79 | print(f'# toc = {self.toc}') 80 | 81 | 82 | def reconstruct(notebook): 83 | """Reconstruct a notebook for debug.""" 84 | expected_signature = parser.SupernoteXParser.SN_SIGNATURES[-1] # latest supported signature 85 | metadata = notebook.get_metadata() 86 | if metadata.signature != expected_signature: 87 | raise ValueError(f'Only latest file format version is supported ({metadata.signature} != {expected_signature})') 88 | 89 | builder = NotebookBuilder() 90 | _pack_type(builder, notebook) 91 | _pack_signature(builder, notebook) 92 | _pack_header(builder, notebook) 93 | _pack_cover(builder, notebook) 94 | _pack_keywords(builder, notebook) 95 | _pack_titles(builder, notebook) 96 | _pack_links(builder, notebook) 97 | _pack_backgrounds(builder, notebook) 98 | _pack_pages(builder, notebook) 99 | _pack_footer(builder) 100 | _pack_tail(builder) 101 | _pack_footer_address(builder) 102 | reconstructed_binary = builder.build() 103 | # check file format integrity 104 | try: 105 | stream = io.BytesIO(reconstructed_binary) 106 | xparser = parser.SupernoteXParser() 107 | _ = xparser.parse_stream(stream) 108 | except Exception as e: 109 | raise exceptions.GeneratedFileValidationException(f'generated file fails validation: {e}') 110 | return reconstructed_binary 111 | 112 | def merge(notebook1, notebook2): 113 | """Merge multiple notebooks to one.""" 114 | # TODO: support non-X series 115 | metadata1 = notebook1.get_metadata() 116 | metadata2 = notebook2.get_metadata() 117 | expected_signature = parser.SupernoteXParser.SN_SIGNATURES[-1] # latest supported signature 118 | if metadata1.signature != expected_signature: 119 | raise ValueError(f'Only latest file format version is supported ({metadata1.signature} != {expected_signature})') 120 | if metadata1.signature != metadata2.signature: 121 | raise ValueError(f'File signature must be same between merging files ({metadata1.signature} != {metadata2.signature})') 122 | if notebook1.get_total_pages() + notebook2.get_total_pages() > 9999: 123 | raise ValueError('The total number of pages are limited to 9999') 124 | # check header properties are same to avoid generating a corrupted note file 125 | _verify_header_property('FILE_TYPE', metadata1, metadata2) 126 | _verify_header_property('APPLY_EQUIPMENT', metadata1, metadata2) 127 | _verify_header_property('DEVICE_DPI', metadata1, metadata2) 128 | _verify_header_property('SOFT_DPI', metadata1, metadata2) 129 | _verify_header_property('FILE_PARSE_TYPE', metadata1, metadata2) 130 | _verify_header_property('RATTA_ETMD', metadata1, metadata2) 131 | _verify_header_property('APP_VERSION', metadata1, metadata2) 132 | 133 | builder = NotebookBuilder() 134 | _pack_type(builder, notebook1) 135 | _pack_signature(builder, notebook1) 136 | _pack_header(builder, notebook1) 137 | _pack_cover(builder, notebook1) 138 | _pack_keywords(builder, notebook1) 139 | _pack_keywords(builder, notebook2, offset=notebook1.get_total_pages()) 140 | _pack_titles(builder, notebook1) 141 | _pack_titles(builder, notebook2, offset=notebook1.get_total_pages()) 142 | _pack_links(builder, notebook1) 143 | _pack_links(builder, notebook2, offset=notebook1.get_total_pages()) 144 | _pack_backgrounds(builder, notebook1) 145 | _pack_backgrounds(builder, notebook2) 146 | _pack_pages(builder, notebook1) 147 | _pack_pages(builder, notebook2, offset=notebook1.get_total_pages()) 148 | _pack_footer(builder) 149 | _pack_tail(builder) 150 | _pack_footer_address(builder) 151 | merged_binary = builder.build() 152 | # check file format integrity 153 | try: 154 | stream = io.BytesIO(merged_binary) 155 | xparser = parser.SupernoteXParser() 156 | _ = xparser.parse_stream(stream) 157 | except Exception as e: 158 | raise exceptions.GeneratedFileValidationException(f'generated file fails validation: {e}') 159 | return merged_binary 160 | 161 | def _pack_type(builder, notebook): 162 | metadata = notebook.get_metadata() 163 | builder.append('__type__', metadata.type.encode('ascii'), skip_block_size=True) 164 | 165 | def _pack_signature(builder, notebook): 166 | metadata = notebook.get_metadata() 167 | builder.append('__signature__', metadata.signature.encode('ascii'), skip_block_size=True) 168 | 169 | def _pack_header(builder, notebook): 170 | metadata = notebook.get_metadata() 171 | header_block = _construct_metadata_block(metadata.header) 172 | builder.append('__header__', header_block) 173 | 174 | def _pack_cover(builder, notebook): 175 | metadata = notebook.get_metadata() 176 | cover_block = notebook.get_cover().get_content() 177 | if cover_block is not None: 178 | builder.append('COVER_2', cover_block) 179 | 180 | def _pack_keywords(builder, notebook, offset=0): 181 | for keyword in notebook.get_keywords(): 182 | page_number = keyword.get_page_number() + 1 + offset 183 | if page_number > 9999: 184 | # the number of digits is limited to 4, so we ignore this keyword 185 | continue 186 | position = keyword.get_position_string() 187 | id = f'{page_number:04d}{position}' 188 | content = keyword.get_content() 189 | if content is not None: 190 | builder.append(f'KEYWORD_{id}', content, allow_duplicate=True) 191 | keyword_metadata = keyword.metadata 192 | keyword_metadata['KEYWORDPAGE'] = page_number 193 | address_list = builder.get_duplicate_block_address_list(f'KEYWORD_{id}') 194 | if len(address_list) == 1: 195 | keyword_metadata['KEYWORDSITE'] = str(address_list[0]) 196 | else: 197 | keyword_metadata['KEYWORDSITE'] = str(address_list[-1]) # use last address 198 | keyword_metadata_block = _construct_metadata_block(keyword_metadata) 199 | builder.append(f'KEYWORD_{id}/metadata', keyword_metadata_block, allow_duplicate=True) 200 | 201 | def _pack_titles(builder, notebook, offset=0): 202 | for title in notebook.get_titles(): 203 | page_number = title.get_page_number() + 1 + offset 204 | if page_number > 9999: 205 | # the number of digits is limited to 4, so we ignore this keyword 206 | continue 207 | position = title.get_position_string() 208 | id = f'{page_number:04d}{position}' 209 | content = title.get_content() 210 | if content is not None: 211 | builder.append(f'TITLE_{id}', content, allow_duplicate=True) 212 | title_metadata = title.metadata 213 | address_list = builder.get_duplicate_block_address_list(f'TITLE_{id}') 214 | if len(address_list) == 1: 215 | title_metadata['TITLEBITMAP'] = str(address_list[0]) 216 | else: 217 | title_metadata['TITLEBITMAP'] = str(address_list[-1]) # use last address 218 | title_metadata_block = _construct_metadata_block(title_metadata) 219 | builder.append(f'TITLE_{id}/metadata', title_metadata_block, allow_duplicate=True) 220 | 221 | def _pack_links(builder, notebook, offset=0): 222 | for link in notebook.get_links(): 223 | page_number = link.get_page_number() + 1 + offset 224 | if page_number > 9999: 225 | # the number of digits is limited to 4, so we ignore this keyword 226 | continue 227 | position = link.get_position_string() 228 | id = f'{page_number:04d}{position}' 229 | content = link.get_content() 230 | if content is not None: 231 | builder.append(f'LINKO_{id}', content, allow_duplicate=True) 232 | link_metadata = link.metadata 233 | address_list = builder.get_duplicate_block_address_list(f'LINKO_{id}') 234 | if len(address_list) == 1: 235 | link_metadata['LINKBITMAP'] = str(address_list[0]) 236 | else: 237 | link_metadata['LINKBITMAP'] = str(address_list[-1]) # use last address 238 | link_metadata_block = _construct_metadata_block(link_metadata) 239 | builder.append(f'LINKO_{id}/metadata', link_metadata_block, allow_duplicate=True) 240 | 241 | def _pack_backgrounds(builder, notebook): 242 | for i in range(notebook.get_total_pages()): 243 | page = notebook.get_page(i) 244 | style = page.get_style() 245 | if style.startswith('user_'): 246 | style += page.get_style_hash() 247 | content = _find_background_content_from_page(page) 248 | if content is not None: 249 | builder.append(f'STYLE_{style}', content) 250 | 251 | def _pack_pages(builder, notebook, offset=0): 252 | for i in range(notebook.get_total_pages()): 253 | page_number = i + 1 + offset 254 | page = notebook.get_page(i) 255 | page = utils.WorkaroundPageWrapper.from_page(page) 256 | # layers 257 | layers = page.get_layers() 258 | for layer in layers: 259 | layer_name = layer.get_name() 260 | if layer_name is None: 261 | continue 262 | if layer_name == 'BGLAYER': 263 | style = page.get_style() 264 | if style.startswith('user_'): 265 | style += page.get_style_hash() 266 | layer_metadata = layer.metadata 267 | layer_metadata['LAYERNAME'] = layer_name 268 | layer_metadata['LAYERBITMAP'] = str(builder.get_block_address(f'STYLE_{style}')) 269 | layer_metadata_block = _construct_metadata_block(layer_metadata) 270 | builder.append(f'PAGE{page_number}/{layer_name}/metadata', layer_metadata_block) 271 | else: 272 | content = layer.get_content() 273 | builder.append(f'PAGE{page_number}/{layer_name}/LAYERBITMAP', content) 274 | layer_metadata = layer.metadata 275 | layer_metadata['LAYERNAME'] = layer_name 276 | layer_metadata['LAYERBITMAP'] = str(builder.get_block_address(f'PAGE{page_number}/{layer_name}/LAYERBITMAP')) 277 | layer_metadata_block = _construct_metadata_block(layer_metadata) 278 | builder.append(f'PAGE{page_number}/{layer_name}/metadata', layer_metadata_block) 279 | # totalpath 280 | totalpath_block = page.get_totalpath() 281 | if totalpath_block is not None: 282 | builder.append(f'PAGE{page_number}/TOTALPATH', totalpath_block) 283 | # page metadata 284 | page_metadata = page.metadata 285 | del page_metadata['__layers__'] 286 | for prop in ['MAINLAYER', 'LAYER1', 'LAYER2', 'LAYER3', 'BGLAYER']: 287 | address = builder.get_block_address(f'PAGE{page_number}/{prop}/metadata') 288 | page_metadata[prop] = address 289 | page_metadata['TOTALPATH'] = builder.get_block_address(f'PAGE{page_number}/TOTALPATH') 290 | page_metadata_block = _construct_metadata_block(page_metadata) 291 | builder.append(f'PAGE{page_number}/metadata', page_metadata_block) 292 | 293 | def _pack_footer(builder): 294 | metadata_footer = {} 295 | metadata_footer.setdefault('FILE_FEATURE', builder.get_block_address('__header__')) 296 | for label in builder.get_labels(): 297 | if re.match(r'PAGE\d+/metadata', label): 298 | address = builder.get_block_address(label) 299 | label = label[:-len('/metadata')] 300 | metadata_footer.setdefault(label, address) 301 | for label in builder.get_labels(): 302 | if re.match(r'TITLE_\d+/metadata', label): 303 | address_list = builder.get_duplicate_block_address_list(label) 304 | label = label[:-len('/metadata')] 305 | if len(address_list) == 1: 306 | metadata_footer.setdefault(label, address_list[0]) 307 | else: 308 | metadata_footer[label] = address_list 309 | for label in builder.get_labels(): 310 | if re.match(r'KEYWORD_\d+/metadata', label): 311 | address_list = builder.get_duplicate_block_address_list(label) 312 | label = label[:-len('/metadata')] 313 | if len(address_list) == 1: 314 | metadata_footer.setdefault(label, address_list[0]) 315 | else: 316 | metadata_footer[label] = address_list 317 | for label in builder.get_labels(): 318 | if re.match(r'LINKO_\d+/metadata', label): 319 | address_list = builder.get_duplicate_block_address_list(label) 320 | label = label[:-len('/metadata')] 321 | if len(address_list) == 1: 322 | metadata_footer.setdefault(label, address_list[0]) 323 | else: 324 | metadata_footer[label] = address_list 325 | address = builder.get_block_address('COVER_2') 326 | if address == 0: 327 | metadata_footer['COVER_0'] = 0 328 | else: 329 | metadata_footer['COVER_2'] = address 330 | for label in builder.get_labels(): 331 | if label.startswith('STYLE_'): 332 | address = builder.get_block_address(label) 333 | metadata_footer.setdefault(label, address) 334 | footer_block = _construct_metadata_block(metadata_footer) 335 | builder.append('__footer__', footer_block) 336 | 337 | def _pack_tail(builder): 338 | builder.append('__tail__', b'tail', skip_block_size=True) 339 | 340 | def _pack_footer_address(builder): 341 | footer_address = builder.get_block_address('__footer__') 342 | builder.append('__footer_address__', footer_address.to_bytes(4, 'little'), skip_block_size=True) 343 | 344 | def _verify_header_property(prop_name, metadata1, metadata2): 345 | if metadata1.header.get(prop_name) != metadata2.header.get(prop_name): 346 | raise ValueError(f'<{prop_name}> property must be same between merging files') 347 | 348 | def _construct_metadata_block(info): 349 | block_data = '' 350 | for k, v in info.items(): 351 | if type(v) == list: 352 | for e in v: 353 | block_data += f'<{k}:{e}>' 354 | else: 355 | block_data += f'<{k}:{v}>' 356 | return block_data.encode('utf-8') 357 | 358 | def _find_background_content_from_page(page): 359 | page = utils.WorkaroundPageWrapper.from_page(page) 360 | if not page.is_layer_supported(): 361 | return None 362 | layers = page.get_layers() 363 | for l in layers: 364 | if l.get_name() == 'BGLAYER': 365 | return l.get_content() 366 | return None 367 | -------------------------------------------------------------------------------- /supernotelib/parser.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Parser classes.""" 16 | 17 | import os 18 | import re 19 | 20 | from . import exceptions 21 | from . import fileformat 22 | 23 | 24 | def parse_metadata(stream, policy='strict'): 25 | """Parses a supernote binary stream and returns metadata object. 26 | 27 | Policy: 28 | - 'strict': raise exception for unknown signature (default) 29 | - 'loose': try to parse for unknown signature 30 | 31 | Parameters 32 | ---------- 33 | stream : file-like object 34 | supernote binary stream 35 | policy : str 36 | signature check policy 37 | 38 | Returns 39 | ------- 40 | SupernoteMetadata 41 | metadata object 42 | """ 43 | try: 44 | parser = SupernoteXParser() 45 | metadata = parser.parse_stream(stream, policy) 46 | except exceptions.UnsupportedFileFormat: 47 | # ignore this exception and try next parser 48 | pass 49 | else: 50 | return metadata 51 | 52 | try: 53 | parser = SupernoteParser() 54 | metadata = parser.parse_stream(stream, policy) 55 | except exceptions.UnsupportedFileFormat: 56 | # ignore this exception and try next parser 57 | pass 58 | else: 59 | return metadata 60 | 61 | # we cannot parse the file with any our parser. 62 | raise exceptions.UnsupportedFileFormat('unsupported file format') 63 | 64 | def load(stream, metadata=None, policy='strict'): 65 | """Creates a Notebook object from the supernote binary stream. 66 | 67 | Policy: 68 | - 'strict': raise exception for unknown signature (default) 69 | - 'loose': try to parse for unknown signature 70 | 71 | Parameters 72 | ---------- 73 | stream : file-like object 74 | supernote binary stream 75 | metadata : SupernoteMetadata 76 | metadata object 77 | policy : str 78 | signature check policy 79 | 80 | Returns 81 | ------- 82 | Notebook 83 | notebook object 84 | """ 85 | if metadata is None: 86 | metadata = parse_metadata(stream, policy) 87 | 88 | note = fileformat.Notebook(metadata) 89 | 90 | cover_address = _get_cover_address(metadata) 91 | if cover_address > 0: 92 | content = _get_content_at_address(stream, cover_address) 93 | note.get_cover().set_content(content) 94 | # store keyword data to notebook object 95 | for keyword in note.get_keywords(): 96 | address = _get_keyword_address(keyword) 97 | content = _get_content_at_address(stream, address) 98 | keyword.set_content(content) 99 | # store title data to notebook object 100 | page_numbers = _get_page_number_from_footer_property(note.get_metadata().footer, 'TITLE_') 101 | for i, title in enumerate(note.get_titles()): 102 | address = _get_title_address(title) 103 | content = _get_content_at_address(stream, address) 104 | title.set_content(content) 105 | title.set_page_number(page_numbers[i]) 106 | # store link data to notebook object 107 | page_numbers = _get_page_number_from_footer_property(note.get_metadata().footer, 'LINK') 108 | for i, link in enumerate(note.get_links()): 109 | address = _get_link_address(link) 110 | content = _get_content_at_address(stream, address) 111 | link.set_content(content) 112 | link.set_page_number(page_numbers[i]) 113 | page_total = metadata.get_total_pages() 114 | for p in range(page_total): 115 | addresses = _get_bitmap_address(metadata, p) 116 | if len(addresses) == 1: # the page has no layers 117 | content = _get_content_at_address(stream, addresses[0]) 118 | note.get_page(p).set_content(content) 119 | else: 120 | for l, addr in enumerate(addresses): 121 | content = _get_content_at_address(stream, addr) 122 | note.get_page(p).get_layer(l).set_content(content) 123 | # store path data to notebook object 124 | totalpath_address = _get_totalpath_address(metadata, p) 125 | if totalpath_address > 0: 126 | content = _get_content_at_address(stream, totalpath_address) 127 | note.get_page(p).set_totalpath(content) 128 | # store recogn file data to notebook object 129 | recogn_file_address = _get_recogn_file_address(metadata, p) 130 | if recogn_file_address > 0: 131 | content = _get_content_at_address(stream, recogn_file_address) 132 | note.get_page(p).set_recogn_file(content) 133 | # store recogn text data to notebook object 134 | recogn_text_address = _get_recogn_text_address(metadata, p) 135 | if recogn_text_address > 0: 136 | content = _get_content_at_address(stream, recogn_text_address) 137 | note.get_page(p).set_recogn_text(content) 138 | return note 139 | 140 | def load_notebook(file_name, metadata=None, policy='strict'): 141 | """Creates a Notebook object from the supernote file. 142 | 143 | Policy: 144 | - 'strict': raise exception for unknown signature (default) 145 | - 'loose': try to parse for unknown signature 146 | 147 | Parameters 148 | ---------- 149 | file_name : str 150 | file path string 151 | metadata : SupernoteMetadata 152 | metadata object 153 | policy : str 154 | signature check policy 155 | 156 | Returns 157 | ------- 158 | Notebook 159 | notebook object 160 | """ 161 | with open(file_name, 'rb') as f: 162 | note = load(f, metadata, policy) 163 | return note 164 | 165 | def _get_content_at_address(fobj, address): 166 | content = None 167 | if address != 0: 168 | fobj.seek(address, os.SEEK_SET) 169 | block_length = int.from_bytes(fobj.read(fileformat.LENGTH_FIELD_SIZE), 'little') 170 | content = fobj.read(block_length) 171 | return content 172 | 173 | def _get_cover_address(metadata): 174 | """Returns cover address. 175 | 176 | Returns 177 | ------- 178 | int 179 | cover address 180 | """ 181 | if 'COVER_2' in metadata.footer: 182 | address = int(metadata.footer['COVER_2']) 183 | elif 'COVER_1' in metadata.footer: 184 | address = int(metadata.footer['COVER_1']) 185 | else: 186 | address = 0 187 | return address 188 | 189 | def _get_keyword_address(keyword): 190 | """Returns keyword content address. 191 | 192 | Returns 193 | ------- 194 | int 195 | keyword content address 196 | """ 197 | return int(keyword.metadata['KEYWORDSITE']) 198 | 199 | def _get_title_address(title): 200 | """Returns title content address. 201 | 202 | Returns 203 | ------- 204 | int 205 | title content address 206 | """ 207 | return int(title.metadata['TITLEBITMAP']) 208 | 209 | def _get_link_address(link): 210 | """Returns link content address. 211 | 212 | Returns 213 | ------- 214 | int 215 | link content address 216 | """ 217 | return int(link.metadata['LINKBITMAP']) 218 | 219 | def _get_bitmap_address(metadata, page_number): 220 | """Returns bitmap address of the given page number. 221 | 222 | Returns 223 | ------- 224 | list of int 225 | bitmap address 226 | """ 227 | addresses = [] 228 | layer_supported = metadata.is_layer_supported(page_number) 229 | if layer_supported: 230 | for l in range(5): # TODO: use constant 231 | address = metadata.pages[page_number][fileformat.KEY_LAYERS][l].get('LAYERBITMAP') 232 | addresses.append(0 if address is None else int(address)) 233 | else: 234 | addresses.append(int(metadata.pages[page_number]['DATA'])) 235 | return addresses 236 | 237 | def _get_totalpath_address(metadata, page_number): 238 | """Returns total path address of the given page number. 239 | 240 | Returns 241 | ------- 242 | int 243 | total path address 244 | """ 245 | if 'TOTALPATH' in metadata.pages[page_number]: 246 | address = int(metadata.pages[page_number]['TOTALPATH']) 247 | else: 248 | address = 0 249 | return address 250 | 251 | def _get_recogn_file_address(metadata, page_number): 252 | """Returns recogn file address of the given page number. 253 | 254 | Returns 255 | ------- 256 | int 257 | recogn file address 258 | """ 259 | if 'RECOGNFILE' in metadata.pages[page_number]: 260 | address = int(metadata.pages[page_number]['RECOGNFILE']) 261 | else: 262 | address = 0 263 | return address 264 | 265 | def _get_recogn_text_address(metadata, page_number): 266 | """Returns recogn text address of the given page number. 267 | 268 | Returns 269 | ------- 270 | int 271 | recogn text address 272 | """ 273 | if 'RECOGNTEXT' in metadata.pages[page_number]: 274 | address = int(metadata.pages[page_number]['RECOGNTEXT']) 275 | else: 276 | address = 0 277 | return address 278 | 279 | def _get_page_number_from_footer_property(footer, prefix): 280 | keys = filter(lambda k : k.startswith(prefix), footer.keys()) 281 | page_numbers = [] 282 | for k in keys: 283 | if type(footer[k]) == list: 284 | for _ in range(len(footer[k])): 285 | page_numbers.append(int(k[6:10]) - 1) 286 | else: 287 | page_numbers.append(int(k[6:10]) - 1) # e.g. get '0123' from 'TITLE_01234567' 288 | return page_numbers 289 | 290 | 291 | class SupernoteParser: 292 | """Parser for original Supernote.""" 293 | SN_SIGNATURE_OFFSET = 0 294 | SN_SIGNATURE_PATTERN = r'SN_FILE_ASA_\d{8}' 295 | SN_SIGNATURES = ['SN_FILE_ASA_20190529'] 296 | 297 | def parse(self, file_name, policy='strict'): 298 | """Parses a Supernote file and returns SupernoteMetadata object. 299 | 300 | Policy: 301 | - 'strict': raise exception for unknown signature (default) 302 | - 'loose': try to parse for unknown signature 303 | 304 | Parameters 305 | ---------- 306 | file_name : str 307 | file path string 308 | policy : str 309 | signature check policy 310 | 311 | Returns 312 | ------- 313 | SupernoteMetadata 314 | metadata of the file 315 | """ 316 | with open(file_name, 'rb') as f: 317 | metadata = self.parse_stream(f, policy) 318 | return metadata 319 | 320 | def parse_stream(self, stream, policy='strict'): 321 | """Parses a Supernote file stream and returns SupernoteMetadata object. 322 | 323 | Policy: 324 | - 'strict': raise exception for unknown signature (default) 325 | - 'loose': try to parse for unknown signature 326 | 327 | Parameters 328 | ---------- 329 | file_name : str 330 | file path string 331 | policy : str 332 | signature check policy 333 | 334 | Returns 335 | ------- 336 | SupernoteMetadata 337 | metadata of the file 338 | """ 339 | # parse file type 340 | filetype = self._parse_filetype(stream) 341 | # check file signature 342 | signature = self._find_matching_signature(stream) 343 | if signature is None: 344 | compatible = self._check_signature_compatible(stream) 345 | if policy != 'loose' or not compatible: 346 | raise exceptions.UnsupportedFileFormat(f'unknown signature: {signature}') 347 | else: 348 | signature = self.SN_SIGNATURES[-1] # treat as latest supported signature 349 | # parse footer block 350 | stream.seek(-fileformat.ADDRESS_SIZE, os.SEEK_END) # footer address is located at last 4-byte 351 | footer_address = int.from_bytes(stream.read(fileformat.ADDRESS_SIZE), 'little') 352 | footer = self._parse_footer_block(stream, footer_address) 353 | # parse header block 354 | header_address = self._get_header_address(footer) 355 | header = self._parse_metadata_block(stream, header_address) 356 | # parse page blocks 357 | page_addresses = self._get_page_addresses(footer) 358 | pages = list(map(lambda addr: self._parse_page_block(stream, addr), page_addresses)) 359 | 360 | metadata = fileformat.SupernoteMetadata() 361 | metadata.type = filetype 362 | metadata.signature = signature 363 | metadata.header = header 364 | metadata.footer = footer 365 | metadata.pages = pages 366 | return metadata 367 | 368 | def _parse_filetype(self, fobj): 369 | fobj.seek(0, os.SEEK_SET) 370 | filetype = fobj.read(4).decode() 371 | return filetype 372 | 373 | def _find_matching_signature(self, fobj): 374 | """Reads signature from file object and returns matching signature. 375 | 376 | Parameters 377 | ---------- 378 | fobj : file 379 | file object 380 | 381 | Returns 382 | ------- 383 | string 384 | matching signature or None if not found 385 | """ 386 | for sig in self.SN_SIGNATURES: 387 | try: 388 | fobj.seek(self.SN_SIGNATURE_OFFSET, os.SEEK_SET) 389 | signature = fobj.read(len(sig)).decode() 390 | except UnicodeDecodeError: 391 | # try next signature 392 | continue 393 | if signature == sig: 394 | return signature 395 | return None 396 | 397 | def _check_signature_compatible(self, fobj): 398 | latest_signature = self.SN_SIGNATURES[-1] 399 | try: 400 | fobj.seek(0, os.SEEK_SET) 401 | signature = fobj.read(len(latest_signature)).decode() 402 | except Exception: 403 | return False 404 | else: 405 | if re.match(self.SN_SIGNATURE_PATTERN, signature): 406 | return True 407 | else: 408 | return False 409 | 410 | def _parse_footer_block(self, fobj, address): 411 | return self._parse_metadata_block(fobj, address) 412 | 413 | def _get_header_address(self, footer): 414 | """Returns header address. 415 | 416 | Parameters 417 | ---------- 418 | footer : dict 419 | footer parameters 420 | 421 | Returns 422 | ------- 423 | int 424 | header address 425 | """ 426 | header_address = int(footer.get('FILE_FEATURE')) 427 | return header_address 428 | 429 | def _get_page_addresses(self, footer): 430 | """Returns list of page addresses. 431 | 432 | Parameters 433 | ---------- 434 | footer : dict 435 | footer parameters 436 | 437 | Returns 438 | ------- 439 | list of int 440 | list of page address 441 | """ 442 | if type(footer.get('PAGE')) == list: 443 | page_addresses = list(map(lambda a: int(a), footer.get('PAGE'))) 444 | else: 445 | page_addresses = [int(footer.get('PAGE'))] 446 | return page_addresses 447 | 448 | def _parse_page_block(self, fobj, address): 449 | """Returns parameters in a page block. 450 | 451 | Parameters 452 | ---------- 453 | fobj : file 454 | file object 455 | address : int 456 | page block address 457 | 458 | Returns 459 | ------- 460 | dict 461 | parameters in the page block 462 | """ 463 | return self._parse_metadata_block(fobj, address) 464 | 465 | def _parse_metadata_block(self, fobj, address): 466 | """Converts metadata block into dict of parameters. 467 | 468 | Returns empty dict if address equals to 0. 469 | 470 | Parameters 471 | ---------- 472 | fobj : file 473 | file object 474 | address : int 475 | metadata block address 476 | 477 | Returns 478 | ------- 479 | dict 480 | extracted parameters 481 | """ 482 | if address == 0: 483 | return {} 484 | fobj.seek(address, os.SEEK_SET) 485 | block_length = int.from_bytes(fobj.read(fileformat.LENGTH_FIELD_SIZE), 'little') 486 | contents = fobj.read(block_length) 487 | params = self._extract_parameters(contents.decode()) 488 | return params 489 | 490 | def _extract_parameters(self, metadata): 491 | """Returns dict of parameters extracted from metadata. 492 | 493 | metadata is a repetition of key-value style parameter like 494 | `...`. 495 | 496 | Parameters 497 | ---------- 498 | metadata : str 499 | metadata string 500 | 501 | Returns 502 | ------- 503 | dict 504 | extracted parameters 505 | """ 506 | pattern = r'<([^:<>]+):([^:<>]*)>' 507 | result = re.finditer(pattern, metadata) 508 | params = {} 509 | for m in result: 510 | key = m[1] 511 | value = m[2] 512 | if params.get(key): 513 | # the key is duplicate. 514 | if type(params.get(key)) != list: 515 | # To store duplicate parameters, we transform data structure 516 | # from {key: value} to {key: [value1, value2, ...]} 517 | first_value = params.pop(key) 518 | params[key] = [first_value, value] 519 | else: 520 | # Data structure have already been transformed. 521 | # We simply append new value to the list. 522 | params[key].append(value) 523 | else: 524 | params[key] = value 525 | return params 526 | 527 | 528 | class SupernoteXParser(SupernoteParser): 529 | """Parser for Supernote X-series.""" 530 | SN_SIGNATURE_OFFSET = 4 531 | SN_SIGNATURE_PATTERN = r'SN_FILE_VER_\d{8}' 532 | SN_SIGNATURES = [ 533 | 'SN_FILE_VER_20200001', # Firmware version C.053 534 | 'SN_FILE_VER_20200005', # Firmware version C.077 535 | 'SN_FILE_VER_20200006', # Firmware version C.130 536 | 'SN_FILE_VER_20200007', # Firmware version C.159 537 | 'SN_FILE_VER_20200008', # Firmware version C.237 538 | 'SN_FILE_VER_20210009', # Firmware version C.291 539 | 'SN_FILE_VER_20210010', # Firmware version Chauvet 2.1.6 540 | 'SN_FILE_VER_20220011', # Firmware version Chauvet 2.5.17 541 | 'SN_FILE_VER_20220013', # Firmware version Chauvet 2.6.19 542 | 'SN_FILE_VER_20230014', # Firmware version Chauvet 2.10.25 543 | 'SN_FILE_VER_20230015' # Firmware version Chauvet 3.14.27 544 | ] 545 | LAYER_KEYS = ['MAINLAYER', 'LAYER1', 'LAYER2', 'LAYER3', 'BGLAYER'] 546 | 547 | def _parse_footer_block(self, fobj, address): 548 | footer = super()._parse_metadata_block(fobj, address) 549 | # parse keywords 550 | keyword_addresses = self._get_keyword_addresses(footer) 551 | keywords = list(map(lambda addr: self._parse_keyword_block(fobj, addr), keyword_addresses)) 552 | if keywords: 553 | footer[fileformat.KEY_KEYWORDS] = keywords 554 | # parse titles 555 | title_addresses = self._get_title_addresses(footer) 556 | titles = list(map(lambda addr: self._parse_title_block(fobj, addr), title_addresses)) 557 | if titles: 558 | footer[fileformat.KEY_TITLES] = titles 559 | # parse links 560 | link_addresses = self._get_link_addresses(footer) 561 | links = list(map(lambda addr: self._parse_link_block(fobj, addr), link_addresses)) 562 | if links: 563 | footer[fileformat.KEY_LINKS] = links 564 | return footer 565 | 566 | def _get_keyword_addresses(self, footer): 567 | keyword_keys = filter(lambda k : k.startswith('KEYWORD_'), footer.keys()) 568 | keyword_addresses = [] 569 | for k in keyword_keys: 570 | if type(footer[k]) == list: 571 | keyword_addresses.extend(list(map(int, footer[k]))) 572 | else: 573 | keyword_addresses.append(int(footer[k])) 574 | return keyword_addresses 575 | 576 | def _parse_keyword_block(self, fobj, address): 577 | return self._parse_metadata_block(fobj, address) 578 | 579 | def _get_title_addresses(self, footer): 580 | title_keys = filter(lambda k : k.startswith('TITLE_'), footer.keys()) 581 | title_addresses = [] 582 | for k in title_keys: 583 | if type(footer[k]) == list: 584 | title_addresses.extend(list(map(int, footer[k]))) 585 | else: 586 | title_addresses.append(int(footer[k])) 587 | return title_addresses 588 | 589 | def _parse_title_block(self, fobj, address): 590 | return self._parse_metadata_block(fobj, address) 591 | 592 | def _get_link_addresses(self, footer): 593 | link_keys = filter(lambda k : k.startswith('LINK'), footer.keys()) 594 | link_addresses = [] 595 | for k in link_keys: 596 | if type(footer[k]) == list: 597 | link_addresses.extend(list(map(int, footer[k]))) 598 | else: 599 | link_addresses.append(int(footer[k])) 600 | return link_addresses 601 | 602 | def _parse_link_block(self, fobj, address): 603 | return self._parse_metadata_block(fobj, address) 604 | 605 | def _get_page_addresses(self, footer): 606 | """Returns list of page addresses. 607 | 608 | Parameters 609 | ---------- 610 | footer : dict 611 | footer parameters 612 | 613 | Returns 614 | ------- 615 | list of int 616 | list of page address 617 | """ 618 | page_keys = filter(lambda k : k.startswith('PAGE'), footer.keys()) 619 | page_addresses = list(map(lambda k: int(footer[k]), page_keys)) 620 | return page_addresses 621 | 622 | def _parse_page_block(self, fobj, address): 623 | """Returns parameters in a page block. 624 | 625 | Parameters 626 | ---------- 627 | fobj : file 628 | file object 629 | address : int 630 | page block address 631 | 632 | Returns 633 | ------- 634 | dict 635 | parameters in the page block 636 | """ 637 | page_info = super()._parse_page_block(fobj, address) 638 | layer_addresses = self._get_layer_addresses(page_info) 639 | layers = list(map(lambda addr: self._parse_layer_block(fobj, addr), layer_addresses)) 640 | page_info[fileformat.KEY_LAYERS] = layers 641 | return page_info 642 | 643 | def _get_layer_addresses(self, page_info): 644 | """Returns list of layer addresses. 645 | 646 | Parameters 647 | ---------- 648 | page_info : dict 649 | page parameters 650 | 651 | Returns 652 | ------- 653 | list of int 654 | list of layer address 655 | """ 656 | layer_keys = filter(lambda k : k in self.LAYER_KEYS, page_info) 657 | layer_addresses = list(map(lambda k: int(page_info[k]), layer_keys)) 658 | return layer_addresses 659 | 660 | def _parse_layer_block(self, fobj, address): 661 | """Returns parameters in a layer block. 662 | 663 | Parameters 664 | ---------- 665 | fobj : file 666 | file object 667 | address : int 668 | layer block address 669 | 670 | Returns 671 | ------- 672 | dict 673 | parameters in the layer block 674 | """ 675 | return self._parse_metadata_block(fobj, address) 676 | -------------------------------------------------------------------------------- /supernotelib/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 jya 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Utility classes.""" 16 | 17 | from . import fileformat 18 | 19 | class WorkaroundPageWrapper(fileformat.Page): 20 | """Workaround for duplicated layer name.""" 21 | def __init__(self, page_info): 22 | super().__init__(page_info) 23 | self._override_layer_name() 24 | 25 | @staticmethod 26 | def from_page(page): 27 | wrapped_page = WorkaroundPageWrapper(page.metadata) 28 | # copy contents from the original page object 29 | wrapped_page.set_content(page.get_content()) 30 | wrapped_page.set_totalpath(page.get_totalpath()) 31 | for i, layer in enumerate(page.get_layers()): 32 | wrapped_page.get_layer(i).set_content(layer.get_content()) 33 | return wrapped_page 34 | 35 | def _override_layer_name(self): 36 | mainlayer_visited = False 37 | for layer in self.get_layers(): 38 | name = layer.get_name() 39 | if name is None: 40 | continue 41 | if mainlayer_visited and name == 'MAINLAYER': 42 | # this layer has duplicated name, so we guess this layer is BGLAYER 43 | layer.metadata['LAYERNAME'] = 'BGLAYER' 44 | elif name == 'MAINLAYER': 45 | mainlayer_visited = True 46 | --------------------------------------------------------------------------------