supernote 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- supernote/__init__.py +13 -0
- supernote/cmds/__init__.py +0 -0
- supernote/cmds/supernote_tool.py +327 -0
- supernote/color.py +93 -0
- supernote/converter.py +543 -0
- supernote/decoder.py +398 -0
- supernote/exceptions.py +43 -0
- supernote/fileformat.py +453 -0
- supernote/manipulator.py +423 -0
- supernote/parser.py +738 -0
- supernote/utils.py +49 -0
- supernote-0.1.0.dist-info/METADATA +34 -0
- supernote-0.1.0.dist-info/RECORD +17 -0
- supernote-0.1.0.dist-info/WHEEL +5 -0
- supernote-0.1.0.dist-info/entry_points.txt +2 -0
- supernote-0.1.0.dist-info/licenses/LICENSE +201 -0
- supernote-0.1.0.dist-info/top_level.txt +1 -0
supernote/parser.py
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
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
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
from . import exceptions
|
|
22
|
+
from . import fileformat
|
|
23
|
+
|
|
24
|
+
ParamsBlock = fileformat.ParamsBlock
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileObj(Protocol):
|
|
28
|
+
"""Protocol for file-like object."""
|
|
29
|
+
|
|
30
|
+
def seek(self, offset: int, whence: int) -> None:
|
|
31
|
+
"""Seeks to the given offset."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def read(self, size: int) -> bytes:
|
|
35
|
+
"""Reads bytes from the stream."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_metadata(stream: FileObj, policy="strict"):
|
|
40
|
+
"""Parses a supernote binary stream and returns metadata object.
|
|
41
|
+
|
|
42
|
+
Policy:
|
|
43
|
+
- 'strict': raise exception for unknown signature (default)
|
|
44
|
+
- 'loose': try to parse for unknown signature
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
stream : file-like object
|
|
49
|
+
supernote binary stream
|
|
50
|
+
policy : str
|
|
51
|
+
signature check policy
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
SupernoteMetadata
|
|
56
|
+
metadata object
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
parser = SupernoteXParser()
|
|
60
|
+
metadata = parser.parse_stream(stream, policy)
|
|
61
|
+
except exceptions.UnsupportedFileFormat:
|
|
62
|
+
# ignore this exception and try next parser
|
|
63
|
+
pass
|
|
64
|
+
else:
|
|
65
|
+
return metadata
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
parser = SupernoteParser()
|
|
69
|
+
metadata = parser.parse_stream(stream, policy)
|
|
70
|
+
except exceptions.UnsupportedFileFormat:
|
|
71
|
+
# ignore this exception and try next parser
|
|
72
|
+
pass
|
|
73
|
+
else:
|
|
74
|
+
return metadata
|
|
75
|
+
|
|
76
|
+
# we cannot parse the file with any our parser.
|
|
77
|
+
raise exceptions.UnsupportedFileFormat("unsupported file format")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(
|
|
81
|
+
stream: FileObj,
|
|
82
|
+
metadata: fileformat.SupernoteMetadata | None = None,
|
|
83
|
+
policy: str = "strict",
|
|
84
|
+
) -> fileformat.Notebook:
|
|
85
|
+
"""Creates a Notebook object from the supernote binary stream.
|
|
86
|
+
|
|
87
|
+
Policy:
|
|
88
|
+
- 'strict': raise exception for unknown signature (default)
|
|
89
|
+
- 'loose': try to parse for unknown signature
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
stream : file-like object
|
|
94
|
+
supernote binary stream
|
|
95
|
+
metadata : SupernoteMetadata
|
|
96
|
+
metadata object
|
|
97
|
+
policy : str
|
|
98
|
+
signature check policy
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
Notebook
|
|
103
|
+
notebook object
|
|
104
|
+
"""
|
|
105
|
+
if metadata is None:
|
|
106
|
+
metadata = parse_metadata(stream, policy)
|
|
107
|
+
|
|
108
|
+
note = fileformat.Notebook(metadata)
|
|
109
|
+
|
|
110
|
+
cover_address = _get_cover_address(metadata)
|
|
111
|
+
if cover_address > 0:
|
|
112
|
+
content = _get_content_at_address(stream, cover_address)
|
|
113
|
+
note.get_cover().set_content(content)
|
|
114
|
+
# store keyword data to notebook object
|
|
115
|
+
for keyword in note.get_keywords():
|
|
116
|
+
address = _get_keyword_address(keyword)
|
|
117
|
+
content = _get_content_at_address(stream, address)
|
|
118
|
+
keyword.set_content(content)
|
|
119
|
+
# store title data to notebook object
|
|
120
|
+
page_numbers = _get_page_number_from_footer_property(
|
|
121
|
+
note.get_metadata().footer, "TITLE_"
|
|
122
|
+
)
|
|
123
|
+
for i, title in enumerate(note.get_titles()):
|
|
124
|
+
address = _get_title_address(title)
|
|
125
|
+
content = _get_content_at_address(stream, address)
|
|
126
|
+
title.set_content(content)
|
|
127
|
+
title.set_page_number(page_numbers[i])
|
|
128
|
+
# store link data to notebook object
|
|
129
|
+
page_numbers = _get_page_number_from_footer_property(
|
|
130
|
+
note.get_metadata().footer, "LINK"
|
|
131
|
+
)
|
|
132
|
+
for i, link in enumerate(note.get_links()):
|
|
133
|
+
address = _get_link_address(link)
|
|
134
|
+
content = _get_content_at_address(stream, address)
|
|
135
|
+
link.set_content(content)
|
|
136
|
+
link.set_page_number(page_numbers[i])
|
|
137
|
+
page_total = metadata.get_total_pages()
|
|
138
|
+
for p in range(page_total):
|
|
139
|
+
addresses = _get_bitmap_address(metadata, p)
|
|
140
|
+
if len(addresses) == 1: # the page has no layers
|
|
141
|
+
content = _get_content_at_address(stream, addresses[0])
|
|
142
|
+
note.get_page(p).set_content(content)
|
|
143
|
+
else:
|
|
144
|
+
for layer, addr in enumerate(addresses):
|
|
145
|
+
content = _get_content_at_address(stream, addr)
|
|
146
|
+
note.get_page(p).get_layer(layer).set_content(content)
|
|
147
|
+
# store path data to notebook object
|
|
148
|
+
totalpath_address = _get_totalpath_address(metadata, p)
|
|
149
|
+
if totalpath_address > 0:
|
|
150
|
+
content = _get_content_at_address(stream, totalpath_address)
|
|
151
|
+
note.get_page(p).set_totalpath(content)
|
|
152
|
+
# store recogn file data to notebook object
|
|
153
|
+
recogn_file_address = _get_recogn_file_address(metadata, p)
|
|
154
|
+
if recogn_file_address > 0:
|
|
155
|
+
content = _get_content_at_address(stream, recogn_file_address)
|
|
156
|
+
note.get_page(p).set_recogn_file(content)
|
|
157
|
+
# store recogn text data to notebook object
|
|
158
|
+
recogn_text_address = _get_recogn_text_address(metadata, p)
|
|
159
|
+
if recogn_text_address > 0:
|
|
160
|
+
content = _get_content_at_address(stream, recogn_text_address)
|
|
161
|
+
note.get_page(p).set_recogn_text(content)
|
|
162
|
+
return note
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_notebook(file_name, metadata=None, policy="strict"):
|
|
166
|
+
"""Creates a Notebook object from the supernote file.
|
|
167
|
+
|
|
168
|
+
Policy:
|
|
169
|
+
- 'strict': raise exception for unknown signature (default)
|
|
170
|
+
- 'loose': try to parse for unknown signature
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
file_name : str
|
|
175
|
+
file path string
|
|
176
|
+
metadata : SupernoteMetadata
|
|
177
|
+
metadata object
|
|
178
|
+
policy : str
|
|
179
|
+
signature check policy
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
Notebook
|
|
184
|
+
notebook object
|
|
185
|
+
"""
|
|
186
|
+
with open(file_name, "rb") as f:
|
|
187
|
+
note = load(f, metadata, policy)
|
|
188
|
+
return note
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get_content_at_address(fobj, address):
|
|
192
|
+
content = None
|
|
193
|
+
if address != 0:
|
|
194
|
+
fobj.seek(address, os.SEEK_SET)
|
|
195
|
+
block_length = int.from_bytes(fobj.read(fileformat.LENGTH_FIELD_SIZE), "little")
|
|
196
|
+
content = fobj.read(block_length)
|
|
197
|
+
return content
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _get_cover_address(metadata):
|
|
201
|
+
"""Returns cover address.
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
int
|
|
206
|
+
cover address
|
|
207
|
+
"""
|
|
208
|
+
if "COVER_2" in metadata.footer:
|
|
209
|
+
address = int(metadata.footer["COVER_2"])
|
|
210
|
+
elif "COVER_1" in metadata.footer:
|
|
211
|
+
address = int(metadata.footer["COVER_1"])
|
|
212
|
+
else:
|
|
213
|
+
address = 0
|
|
214
|
+
return address
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _get_keyword_address(keyword):
|
|
218
|
+
"""Returns keyword content address.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
int
|
|
223
|
+
keyword content address
|
|
224
|
+
"""
|
|
225
|
+
return int(keyword.metadata["KEYWORDSITE"])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _get_title_address(title):
|
|
229
|
+
"""Returns title content address.
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
int
|
|
234
|
+
title content address
|
|
235
|
+
"""
|
|
236
|
+
return int(title.metadata["TITLEBITMAP"])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _get_link_address(link):
|
|
240
|
+
"""Returns link content address.
|
|
241
|
+
|
|
242
|
+
Returns
|
|
243
|
+
-------
|
|
244
|
+
int
|
|
245
|
+
link content address
|
|
246
|
+
"""
|
|
247
|
+
return int(link.metadata["LINKBITMAP"])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _get_bitmap_address(metadata, page_number):
|
|
251
|
+
"""Returns bitmap address of the given page number.
|
|
252
|
+
|
|
253
|
+
Returns
|
|
254
|
+
-------
|
|
255
|
+
list of int
|
|
256
|
+
bitmap address
|
|
257
|
+
"""
|
|
258
|
+
addresses = []
|
|
259
|
+
layer_supported = metadata.is_layer_supported(page_number)
|
|
260
|
+
if layer_supported:
|
|
261
|
+
for layer in range(5): # TODO: use constant
|
|
262
|
+
address = metadata.pages[page_number][fileformat.KEY_LAYERS][layer].get(
|
|
263
|
+
"LAYERBITMAP"
|
|
264
|
+
)
|
|
265
|
+
addresses.append(0 if address is None else int(address))
|
|
266
|
+
else:
|
|
267
|
+
addresses.append(int(metadata.pages[page_number]["DATA"]))
|
|
268
|
+
return addresses
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _get_totalpath_address(
|
|
272
|
+
metadata: fileformat.SupernoteMetadata, page_number: int
|
|
273
|
+
) -> int:
|
|
274
|
+
"""Returns total path address of the given page number.
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
int
|
|
279
|
+
total path address
|
|
280
|
+
"""
|
|
281
|
+
if "TOTALPATH" in metadata.pages[page_number]:
|
|
282
|
+
address = int(metadata.pages[page_number]["TOTALPATH"])
|
|
283
|
+
else:
|
|
284
|
+
address = 0
|
|
285
|
+
return address
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _get_recogn_file_address(metadata, page_number):
|
|
289
|
+
"""Returns recogn file address of the given page number.
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
int
|
|
294
|
+
recogn file address
|
|
295
|
+
"""
|
|
296
|
+
if "RECOGNFILE" in metadata.pages[page_number]:
|
|
297
|
+
address = int(metadata.pages[page_number]["RECOGNFILE"])
|
|
298
|
+
else:
|
|
299
|
+
address = 0
|
|
300
|
+
return address
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _get_recogn_text_address(metadata, page_number):
|
|
304
|
+
"""Returns recogn text address of the given page number.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
int
|
|
309
|
+
recogn text address
|
|
310
|
+
"""
|
|
311
|
+
if "RECOGNTEXT" in metadata.pages[page_number]:
|
|
312
|
+
address = int(metadata.pages[page_number]["RECOGNTEXT"])
|
|
313
|
+
else:
|
|
314
|
+
address = 0
|
|
315
|
+
return address
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _get_page_number_from_footer_property(footer, prefix):
|
|
319
|
+
keys = filter(lambda k: k.startswith(prefix), footer.keys())
|
|
320
|
+
page_numbers = []
|
|
321
|
+
for k in keys:
|
|
322
|
+
if type(footer[k]) is list:
|
|
323
|
+
for _ in range(len(footer[k])):
|
|
324
|
+
page_numbers.append(int(k[6:10]) - 1)
|
|
325
|
+
else:
|
|
326
|
+
page_numbers.append(
|
|
327
|
+
int(k[6:10]) - 1
|
|
328
|
+
) # e.g. get '0123' from 'TITLE_01234567'
|
|
329
|
+
return page_numbers
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SupernoteParser:
|
|
333
|
+
"""Parser for original Supernote."""
|
|
334
|
+
|
|
335
|
+
SN_SIGNATURE_OFFSET = 0
|
|
336
|
+
SN_SIGNATURE_PATTERN = r"SN_FILE_ASA_\d{8}"
|
|
337
|
+
SN_SIGNATURES = ["SN_FILE_ASA_20190529"]
|
|
338
|
+
|
|
339
|
+
def parse(
|
|
340
|
+
self, file_name: str, policy: str = "strict"
|
|
341
|
+
) -> fileformat.SupernoteMetadata:
|
|
342
|
+
"""Parses a Supernote file and returns SupernoteMetadata object.
|
|
343
|
+
|
|
344
|
+
Policy:
|
|
345
|
+
- 'strict': raise exception for unknown signature (default)
|
|
346
|
+
- 'loose': try to parse for unknown signature
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
file_name : str
|
|
351
|
+
file path string
|
|
352
|
+
policy : str
|
|
353
|
+
signature check policy
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
SupernoteMetadata
|
|
358
|
+
metadata of the file
|
|
359
|
+
"""
|
|
360
|
+
with open(file_name, "rb") as f:
|
|
361
|
+
metadata = self.parse_stream(f, policy)
|
|
362
|
+
return metadata
|
|
363
|
+
|
|
364
|
+
def parse_stream(
|
|
365
|
+
self, stream: FileObj, policy: str = "strict"
|
|
366
|
+
) -> fileformat.SupernoteMetadata:
|
|
367
|
+
"""Parses a Supernote file stream and returns SupernoteMetadata object.
|
|
368
|
+
|
|
369
|
+
Policy:
|
|
370
|
+
- 'strict': raise exception for unknown signature (default)
|
|
371
|
+
- 'loose': try to parse for unknown signature
|
|
372
|
+
|
|
373
|
+
Parameters
|
|
374
|
+
----------
|
|
375
|
+
file_name : str
|
|
376
|
+
file path string
|
|
377
|
+
policy : str
|
|
378
|
+
signature check policy
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
SupernoteMetadata
|
|
383
|
+
metadata of the file
|
|
384
|
+
"""
|
|
385
|
+
# parse file type
|
|
386
|
+
filetype = self._parse_filetype(stream)
|
|
387
|
+
# check file signature
|
|
388
|
+
signature = self._find_matching_signature(stream)
|
|
389
|
+
if signature is None:
|
|
390
|
+
compatible = self._check_signature_compatible(stream)
|
|
391
|
+
if policy != "loose" or not compatible:
|
|
392
|
+
raise exceptions.UnsupportedFileFormat(
|
|
393
|
+
f"unknown signature: {signature}"
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
signature = self.SN_SIGNATURES[
|
|
397
|
+
-1
|
|
398
|
+
] # treat as latest supported signature
|
|
399
|
+
# parse footer block
|
|
400
|
+
stream.seek(
|
|
401
|
+
-fileformat.ADDRESS_SIZE, os.SEEK_END
|
|
402
|
+
) # footer address is located at last 4-byte
|
|
403
|
+
footer_address = int.from_bytes(stream.read(fileformat.ADDRESS_SIZE), "little")
|
|
404
|
+
footer = self._parse_footer_block(stream, footer_address)
|
|
405
|
+
# parse header block
|
|
406
|
+
header_address = self._get_header_address(footer)
|
|
407
|
+
header = self._parse_metadata_block(stream, header_address)
|
|
408
|
+
# parse page blocks
|
|
409
|
+
page_addresses = self._get_page_addresses(footer)
|
|
410
|
+
pages = list(
|
|
411
|
+
map(lambda addr: self._parse_page_block(stream, addr), page_addresses)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
metadata = fileformat.SupernoteMetadata()
|
|
415
|
+
metadata.type = filetype
|
|
416
|
+
metadata.signature = signature
|
|
417
|
+
metadata.header = header
|
|
418
|
+
metadata.footer = footer
|
|
419
|
+
metadata.pages = pages
|
|
420
|
+
return metadata
|
|
421
|
+
|
|
422
|
+
def _parse_filetype(self, fobj: FileObj) -> str:
|
|
423
|
+
fobj.seek(0, os.SEEK_SET)
|
|
424
|
+
filetype = fobj.read(4).decode()
|
|
425
|
+
return filetype
|
|
426
|
+
|
|
427
|
+
def _find_matching_signature(self, fobj: FileObj) -> str | None:
|
|
428
|
+
"""Reads signature from file object and returns matching signature.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
fobj : file
|
|
433
|
+
file object
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
string
|
|
438
|
+
matching signature or None if not found
|
|
439
|
+
"""
|
|
440
|
+
for sig in self.SN_SIGNATURES:
|
|
441
|
+
try:
|
|
442
|
+
fobj.seek(self.SN_SIGNATURE_OFFSET, os.SEEK_SET)
|
|
443
|
+
signature = fobj.read(len(sig)).decode()
|
|
444
|
+
except UnicodeDecodeError:
|
|
445
|
+
# try next signature
|
|
446
|
+
continue
|
|
447
|
+
if signature == sig:
|
|
448
|
+
return signature
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
def _check_signature_compatible(self, fobj: FileObj) -> bool:
|
|
452
|
+
latest_signature = self.SN_SIGNATURES[-1]
|
|
453
|
+
try:
|
|
454
|
+
fobj.seek(0, os.SEEK_SET)
|
|
455
|
+
signature = fobj.read(len(latest_signature)).decode()
|
|
456
|
+
except Exception:
|
|
457
|
+
return False
|
|
458
|
+
else:
|
|
459
|
+
if re.match(self.SN_SIGNATURE_PATTERN, signature):
|
|
460
|
+
return True
|
|
461
|
+
else:
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
def _parse_footer_block(self, fobj: FileObj, address: int) -> ParamsBlock:
|
|
465
|
+
return self._parse_metadata_block(fobj, address)
|
|
466
|
+
|
|
467
|
+
def _get_header_address(self, footer: ParamsBlock) -> int:
|
|
468
|
+
"""Returns header address.
|
|
469
|
+
|
|
470
|
+
Parameters
|
|
471
|
+
----------
|
|
472
|
+
footer : dict
|
|
473
|
+
footer parameters
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
int
|
|
478
|
+
header address
|
|
479
|
+
"""
|
|
480
|
+
header_address = int(footer.get("FILE_FEATURE"))
|
|
481
|
+
return header_address
|
|
482
|
+
|
|
483
|
+
def _get_page_addresses(self, footer: ParamsBlock) -> list[int]:
|
|
484
|
+
"""Returns list of page addresses.
|
|
485
|
+
|
|
486
|
+
Parameters
|
|
487
|
+
----------
|
|
488
|
+
footer : dict
|
|
489
|
+
footer parameters
|
|
490
|
+
|
|
491
|
+
Returns
|
|
492
|
+
-------
|
|
493
|
+
list of int
|
|
494
|
+
list of page address
|
|
495
|
+
"""
|
|
496
|
+
if type(footer.get("PAGE")) is list:
|
|
497
|
+
page_addresses = list(map(lambda a: int(a), footer.get("PAGE")))
|
|
498
|
+
else:
|
|
499
|
+
page_addresses = [int(footer.get("PAGE"))]
|
|
500
|
+
return page_addresses
|
|
501
|
+
|
|
502
|
+
def _parse_page_block(self, fobj: FileObj, address: int) -> ParamsBlock:
|
|
503
|
+
"""Returns parameters in a page block.
|
|
504
|
+
|
|
505
|
+
Parameters
|
|
506
|
+
----------
|
|
507
|
+
fobj : file
|
|
508
|
+
file object
|
|
509
|
+
address : int
|
|
510
|
+
page block address
|
|
511
|
+
|
|
512
|
+
Returns
|
|
513
|
+
-------
|
|
514
|
+
dict
|
|
515
|
+
parameters in the page block
|
|
516
|
+
"""
|
|
517
|
+
return self._parse_metadata_block(fobj, address)
|
|
518
|
+
|
|
519
|
+
def _parse_metadata_block(self, fobj: FileObj, address: int) -> ParamsBlock:
|
|
520
|
+
"""Converts metadata block into dict of parameters.
|
|
521
|
+
|
|
522
|
+
Returns empty dict if address equals to 0.
|
|
523
|
+
|
|
524
|
+
Parameters
|
|
525
|
+
----------
|
|
526
|
+
fobj : file
|
|
527
|
+
file object
|
|
528
|
+
address : int
|
|
529
|
+
metadata block address
|
|
530
|
+
|
|
531
|
+
Returns
|
|
532
|
+
-------
|
|
533
|
+
dict
|
|
534
|
+
extracted parameters
|
|
535
|
+
"""
|
|
536
|
+
if address == 0:
|
|
537
|
+
return {}
|
|
538
|
+
fobj.seek(address, os.SEEK_SET)
|
|
539
|
+
block_length = int.from_bytes(fobj.read(fileformat.LENGTH_FIELD_SIZE), "little")
|
|
540
|
+
contents = fobj.read(block_length)
|
|
541
|
+
params = self._extract_parameters(contents.decode())
|
|
542
|
+
return params
|
|
543
|
+
|
|
544
|
+
def _extract_parameters(self, metadata: str) -> ParamsBlock:
|
|
545
|
+
"""Returns dict of parameters extracted from metadata.
|
|
546
|
+
|
|
547
|
+
metadata is a repetition of key-value style parameter like
|
|
548
|
+
`<KEY1:VALUE1><KEY2:VALUE2>...`.
|
|
549
|
+
|
|
550
|
+
Parameters
|
|
551
|
+
----------
|
|
552
|
+
metadata : str
|
|
553
|
+
metadata string
|
|
554
|
+
|
|
555
|
+
Returns
|
|
556
|
+
-------
|
|
557
|
+
dict
|
|
558
|
+
extracted parameters
|
|
559
|
+
"""
|
|
560
|
+
pattern = r"<([^:<>]+):([^:<>]*)>"
|
|
561
|
+
result = re.finditer(pattern, metadata)
|
|
562
|
+
params = {}
|
|
563
|
+
for m in result:
|
|
564
|
+
key = m[1]
|
|
565
|
+
value = m[2]
|
|
566
|
+
if params.get(key):
|
|
567
|
+
# the key is duplicate.
|
|
568
|
+
if type(params.get(key)) is not list:
|
|
569
|
+
# To store duplicate parameters, we transform data structure
|
|
570
|
+
# from {key: value} to {key: [value1, value2, ...]}
|
|
571
|
+
first_value = params.pop(key)
|
|
572
|
+
params[key] = [first_value, value]
|
|
573
|
+
else:
|
|
574
|
+
# Data structure have already been transformed.
|
|
575
|
+
# We simply append new value to the list.
|
|
576
|
+
params[key].append(value)
|
|
577
|
+
else:
|
|
578
|
+
params[key] = value
|
|
579
|
+
return params
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class SupernoteXParser(SupernoteParser):
|
|
583
|
+
"""Parser for Supernote X-series."""
|
|
584
|
+
|
|
585
|
+
SN_SIGNATURE_OFFSET = 4
|
|
586
|
+
SN_SIGNATURE_PATTERN = r"SN_FILE_VER_\d{8}"
|
|
587
|
+
SN_SIGNATURES = [
|
|
588
|
+
"SN_FILE_VER_20200001", # Firmware version C.053
|
|
589
|
+
"SN_FILE_VER_20200005", # Firmware version C.077
|
|
590
|
+
"SN_FILE_VER_20200006", # Firmware version C.130
|
|
591
|
+
"SN_FILE_VER_20200007", # Firmware version C.159
|
|
592
|
+
"SN_FILE_VER_20200008", # Firmware version C.237
|
|
593
|
+
"SN_FILE_VER_20210009", # Firmware version C.291
|
|
594
|
+
"SN_FILE_VER_20210010", # Firmware version Chauvet 2.1.6
|
|
595
|
+
"SN_FILE_VER_20220011", # Firmware version Chauvet 2.5.17
|
|
596
|
+
"SN_FILE_VER_20220013", # Firmware version Chauvet 2.6.19
|
|
597
|
+
"SN_FILE_VER_20230014", # Firmware version Chauvet 2.10.25
|
|
598
|
+
"SN_FILE_VER_20230015", # Firmware version Chauvet 3.14.27
|
|
599
|
+
]
|
|
600
|
+
LAYER_KEYS = ["MAINLAYER", "LAYER1", "LAYER2", "LAYER3", "BGLAYER"]
|
|
601
|
+
|
|
602
|
+
def _parse_footer_block(self, fobj, address):
|
|
603
|
+
footer = super()._parse_metadata_block(fobj, address)
|
|
604
|
+
# parse keywords
|
|
605
|
+
keyword_addresses = self._get_keyword_addresses(footer)
|
|
606
|
+
keywords = list(
|
|
607
|
+
map(lambda addr: self._parse_keyword_block(fobj, addr), keyword_addresses)
|
|
608
|
+
)
|
|
609
|
+
if keywords:
|
|
610
|
+
footer[fileformat.KEY_KEYWORDS] = keywords
|
|
611
|
+
# parse titles
|
|
612
|
+
title_addresses = self._get_title_addresses(footer)
|
|
613
|
+
titles = list(
|
|
614
|
+
map(lambda addr: self._parse_title_block(fobj, addr), title_addresses)
|
|
615
|
+
)
|
|
616
|
+
if titles:
|
|
617
|
+
footer[fileformat.KEY_TITLES] = titles
|
|
618
|
+
# parse links
|
|
619
|
+
link_addresses = self._get_link_addresses(footer)
|
|
620
|
+
links = list(
|
|
621
|
+
map(lambda addr: self._parse_link_block(fobj, addr), link_addresses)
|
|
622
|
+
)
|
|
623
|
+
if links:
|
|
624
|
+
footer[fileformat.KEY_LINKS] = links
|
|
625
|
+
return footer
|
|
626
|
+
|
|
627
|
+
def _get_keyword_addresses(self, footer):
|
|
628
|
+
keyword_keys = filter(lambda k: k.startswith("KEYWORD_"), footer.keys())
|
|
629
|
+
keyword_addresses = []
|
|
630
|
+
for k in keyword_keys:
|
|
631
|
+
if type(footer[k]) is list:
|
|
632
|
+
keyword_addresses.extend(list(map(int, footer[k])))
|
|
633
|
+
else:
|
|
634
|
+
keyword_addresses.append(int(footer[k]))
|
|
635
|
+
return keyword_addresses
|
|
636
|
+
|
|
637
|
+
def _parse_keyword_block(self, fobj, address):
|
|
638
|
+
return self._parse_metadata_block(fobj, address)
|
|
639
|
+
|
|
640
|
+
def _get_title_addresses(self, footer):
|
|
641
|
+
title_keys = filter(lambda k: k.startswith("TITLE_"), footer.keys())
|
|
642
|
+
title_addresses = []
|
|
643
|
+
for k in title_keys:
|
|
644
|
+
if type(footer[k]) is list:
|
|
645
|
+
title_addresses.extend(list(map(int, footer[k])))
|
|
646
|
+
else:
|
|
647
|
+
title_addresses.append(int(footer[k]))
|
|
648
|
+
return title_addresses
|
|
649
|
+
|
|
650
|
+
def _parse_title_block(self, fobj, address):
|
|
651
|
+
return self._parse_metadata_block(fobj, address)
|
|
652
|
+
|
|
653
|
+
def _get_link_addresses(self, footer):
|
|
654
|
+
link_keys = filter(lambda k: k.startswith("LINK"), footer.keys())
|
|
655
|
+
link_addresses = []
|
|
656
|
+
for k in link_keys:
|
|
657
|
+
if type(footer[k]) is list:
|
|
658
|
+
link_addresses.extend(list(map(int, footer[k])))
|
|
659
|
+
else:
|
|
660
|
+
link_addresses.append(int(footer[k]))
|
|
661
|
+
return link_addresses
|
|
662
|
+
|
|
663
|
+
def _parse_link_block(self, fobj, address):
|
|
664
|
+
return self._parse_metadata_block(fobj, address)
|
|
665
|
+
|
|
666
|
+
def _get_page_addresses(self, footer: ParamsBlock) -> list[int]:
|
|
667
|
+
"""Returns list of page addresses.
|
|
668
|
+
|
|
669
|
+
Parameters
|
|
670
|
+
----------
|
|
671
|
+
footer : dict
|
|
672
|
+
footer parameters
|
|
673
|
+
|
|
674
|
+
Returns
|
|
675
|
+
-------
|
|
676
|
+
list of int
|
|
677
|
+
list of page address
|
|
678
|
+
"""
|
|
679
|
+
page_keys = filter(lambda k: k.startswith("PAGE"), footer.keys())
|
|
680
|
+
page_addresses = list(map(lambda k: int(footer[k]), page_keys))
|
|
681
|
+
return page_addresses
|
|
682
|
+
|
|
683
|
+
def _parse_page_block(self, fobj, address):
|
|
684
|
+
"""Returns parameters in a page block.
|
|
685
|
+
|
|
686
|
+
Parameters
|
|
687
|
+
----------
|
|
688
|
+
fobj : file
|
|
689
|
+
file object
|
|
690
|
+
address : int
|
|
691
|
+
page block address
|
|
692
|
+
|
|
693
|
+
Returns
|
|
694
|
+
-------
|
|
695
|
+
dict
|
|
696
|
+
parameters in the page block
|
|
697
|
+
"""
|
|
698
|
+
page_info = super()._parse_page_block(fobj, address)
|
|
699
|
+
layer_addresses = self._get_layer_addresses(page_info)
|
|
700
|
+
layers = list(
|
|
701
|
+
map(lambda addr: self._parse_layer_block(fobj, addr), layer_addresses)
|
|
702
|
+
)
|
|
703
|
+
page_info[fileformat.KEY_LAYERS] = layers
|
|
704
|
+
return page_info
|
|
705
|
+
|
|
706
|
+
def _get_layer_addresses(self, page_info):
|
|
707
|
+
"""Returns list of layer addresses.
|
|
708
|
+
|
|
709
|
+
Parameters
|
|
710
|
+
----------
|
|
711
|
+
page_info : dict
|
|
712
|
+
page parameters
|
|
713
|
+
|
|
714
|
+
Returns
|
|
715
|
+
-------
|
|
716
|
+
list of int
|
|
717
|
+
list of layer address
|
|
718
|
+
"""
|
|
719
|
+
layer_keys = filter(lambda k: k in self.LAYER_KEYS, page_info)
|
|
720
|
+
layer_addresses = list(map(lambda k: int(page_info[k]), layer_keys))
|
|
721
|
+
return layer_addresses
|
|
722
|
+
|
|
723
|
+
def _parse_layer_block(self, fobj, address):
|
|
724
|
+
"""Returns parameters in a layer block.
|
|
725
|
+
|
|
726
|
+
Parameters
|
|
727
|
+
----------
|
|
728
|
+
fobj : file
|
|
729
|
+
file object
|
|
730
|
+
address : int
|
|
731
|
+
layer block address
|
|
732
|
+
|
|
733
|
+
Returns
|
|
734
|
+
-------
|
|
735
|
+
dict
|
|
736
|
+
parameters in the layer block
|
|
737
|
+
"""
|
|
738
|
+
return self._parse_metadata_block(fobj, address)
|