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.
@@ -0,0 +1,423 @@
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 re
19
+
20
+ from . import exceptions
21
+ from . import fileformat
22
+ from . import parser
23
+ from . import utils
24
+
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)) is 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)) is 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(
60
+ block_size.to_bytes(fileformat.LENGTH_FIELD_SIZE, "little")
61
+ )
62
+ self.blocks.append(block)
63
+ if label_duplicated:
64
+ if type(self.toc[label]) is list:
65
+ self.toc[label].append(self.total_size)
66
+ else:
67
+ self.toc[label] = [self.toc[label], self.total_size]
68
+ else:
69
+ self.toc.setdefault(label, self.total_size)
70
+ self.total_size += block_size
71
+ if not skip_block_size:
72
+ self.total_size += fileformat.LENGTH_FIELD_SIZE
73
+ return True
74
+
75
+ def build(self):
76
+ return b"".join(self.blocks)
77
+
78
+ def dump(self):
79
+ print("# NotebookBuilder Dump:")
80
+ print(f"# total_size = {self.total_size}")
81
+ print(f"# toc = {self.toc}")
82
+
83
+
84
+ def reconstruct(notebook):
85
+ """Reconstruct a notebook for debug."""
86
+ expected_signature = parser.SupernoteXParser.SN_SIGNATURES[
87
+ -1
88
+ ] # latest supported signature
89
+ metadata = notebook.get_metadata()
90
+ if metadata.signature != expected_signature:
91
+ raise ValueError(
92
+ f"Only latest file format version is supported ({metadata.signature} != {expected_signature})"
93
+ )
94
+
95
+ builder = NotebookBuilder()
96
+ _pack_type(builder, notebook)
97
+ _pack_signature(builder, notebook)
98
+ _pack_header(builder, notebook)
99
+ _pack_cover(builder, notebook)
100
+ _pack_keywords(builder, notebook)
101
+ _pack_titles(builder, notebook)
102
+ _pack_links(builder, notebook)
103
+ _pack_backgrounds(builder, notebook)
104
+ _pack_pages(builder, notebook)
105
+ _pack_footer(builder)
106
+ _pack_tail(builder)
107
+ _pack_footer_address(builder)
108
+ reconstructed_binary = builder.build()
109
+ # check file format integrity
110
+ try:
111
+ stream = io.BytesIO(reconstructed_binary)
112
+ xparser = parser.SupernoteXParser()
113
+ _ = xparser.parse_stream(stream)
114
+ except Exception as e:
115
+ raise exceptions.GeneratedFileValidationException(
116
+ f"generated file fails validation: {e}"
117
+ )
118
+ return reconstructed_binary
119
+
120
+
121
+ def merge(notebook1, notebook2):
122
+ """Merge multiple notebooks to one."""
123
+ # TODO: support non-X series
124
+ metadata1 = notebook1.get_metadata()
125
+ metadata2 = notebook2.get_metadata()
126
+ expected_signature = parser.SupernoteXParser.SN_SIGNATURES[
127
+ -1
128
+ ] # latest supported signature
129
+ if metadata1.signature != expected_signature:
130
+ raise ValueError(
131
+ f"Only latest file format version is supported ({metadata1.signature} != {expected_signature})"
132
+ )
133
+ if metadata1.signature != metadata2.signature:
134
+ raise ValueError(
135
+ f"File signature must be same between merging files ({metadata1.signature} != {metadata2.signature})"
136
+ )
137
+ if notebook1.get_total_pages() + notebook2.get_total_pages() > 9999:
138
+ raise ValueError("The total number of pages are limited to 9999")
139
+ # check header properties are same to avoid generating a corrupted note file
140
+ _verify_header_property("FILE_TYPE", metadata1, metadata2)
141
+ _verify_header_property("APPLY_EQUIPMENT", metadata1, metadata2)
142
+ _verify_header_property("DEVICE_DPI", metadata1, metadata2)
143
+ _verify_header_property("SOFT_DPI", metadata1, metadata2)
144
+ _verify_header_property("FILE_PARSE_TYPE", metadata1, metadata2)
145
+ _verify_header_property("RATTA_ETMD", metadata1, metadata2)
146
+ _verify_header_property("APP_VERSION", metadata1, metadata2)
147
+
148
+ builder = NotebookBuilder()
149
+ _pack_type(builder, notebook1)
150
+ _pack_signature(builder, notebook1)
151
+ _pack_header(builder, notebook1)
152
+ _pack_cover(builder, notebook1)
153
+ _pack_keywords(builder, notebook1)
154
+ _pack_keywords(builder, notebook2, offset=notebook1.get_total_pages())
155
+ _pack_titles(builder, notebook1)
156
+ _pack_titles(builder, notebook2, offset=notebook1.get_total_pages())
157
+ _pack_links(builder, notebook1)
158
+ _pack_links(builder, notebook2, offset=notebook1.get_total_pages())
159
+ _pack_backgrounds(builder, notebook1)
160
+ _pack_backgrounds(builder, notebook2)
161
+ _pack_pages(builder, notebook1)
162
+ _pack_pages(builder, notebook2, offset=notebook1.get_total_pages())
163
+ _pack_footer(builder)
164
+ _pack_tail(builder)
165
+ _pack_footer_address(builder)
166
+ merged_binary = builder.build()
167
+ # check file format integrity
168
+ try:
169
+ stream = io.BytesIO(merged_binary)
170
+ xparser = parser.SupernoteXParser()
171
+ _ = xparser.parse_stream(stream)
172
+ except Exception as e:
173
+ raise exceptions.GeneratedFileValidationException(
174
+ f"generated file fails validation: {e}"
175
+ )
176
+ return merged_binary
177
+
178
+
179
+ def _pack_type(builder, notebook):
180
+ metadata = notebook.get_metadata()
181
+ builder.append("__type__", metadata.type.encode("ascii"), skip_block_size=True)
182
+
183
+
184
+ def _pack_signature(builder, notebook):
185
+ metadata = notebook.get_metadata()
186
+ builder.append(
187
+ "__signature__", metadata.signature.encode("ascii"), skip_block_size=True
188
+ )
189
+
190
+
191
+ def _pack_header(builder, notebook):
192
+ metadata = notebook.get_metadata()
193
+ header_block = _construct_metadata_block(metadata.header)
194
+ builder.append("__header__", header_block)
195
+
196
+
197
+ def _pack_cover(builder, notebook):
198
+ cover_block = notebook.get_cover().get_content()
199
+ if cover_block is not None:
200
+ builder.append("COVER_2", cover_block)
201
+
202
+
203
+ def _pack_keywords(builder, notebook, offset=0):
204
+ for keyword in notebook.get_keywords():
205
+ page_number = keyword.get_page_number() + 1 + offset
206
+ if page_number > 9999:
207
+ # the number of digits is limited to 4, so we ignore this keyword
208
+ continue
209
+ position = keyword.get_position_string()
210
+ id = f"{page_number:04d}{position}"
211
+ content = keyword.get_content()
212
+ if content is not None:
213
+ builder.append(f"KEYWORD_{id}", content, allow_duplicate=True)
214
+ keyword_metadata = keyword.metadata
215
+ keyword_metadata["KEYWORDPAGE"] = page_number
216
+ address_list = builder.get_duplicate_block_address_list(f"KEYWORD_{id}")
217
+ if len(address_list) == 1:
218
+ keyword_metadata["KEYWORDSITE"] = str(address_list[0])
219
+ else:
220
+ keyword_metadata["KEYWORDSITE"] = str(
221
+ address_list[-1]
222
+ ) # use last address
223
+ keyword_metadata_block = _construct_metadata_block(keyword_metadata)
224
+ builder.append(
225
+ f"KEYWORD_{id}/metadata", keyword_metadata_block, allow_duplicate=True
226
+ )
227
+
228
+
229
+ def _pack_titles(builder, notebook, offset=0):
230
+ for title in notebook.get_titles():
231
+ page_number = title.get_page_number() + 1 + offset
232
+ if page_number > 9999:
233
+ # the number of digits is limited to 4, so we ignore this keyword
234
+ continue
235
+ position = title.get_position_string()
236
+ id = f"{page_number:04d}{position}"
237
+ content = title.get_content()
238
+ if content is not None:
239
+ builder.append(f"TITLE_{id}", content, allow_duplicate=True)
240
+ title_metadata = title.metadata
241
+ address_list = builder.get_duplicate_block_address_list(f"TITLE_{id}")
242
+ if len(address_list) == 1:
243
+ title_metadata["TITLEBITMAP"] = str(address_list[0])
244
+ else:
245
+ title_metadata["TITLEBITMAP"] = str(
246
+ address_list[-1]
247
+ ) # use last address
248
+ title_metadata_block = _construct_metadata_block(title_metadata)
249
+ builder.append(
250
+ f"TITLE_{id}/metadata", title_metadata_block, allow_duplicate=True
251
+ )
252
+
253
+
254
+ def _pack_links(builder, notebook, offset=0):
255
+ for link in notebook.get_links():
256
+ page_number = link.get_page_number() + 1 + offset
257
+ if page_number > 9999:
258
+ # the number of digits is limited to 4, so we ignore this keyword
259
+ continue
260
+ position = link.get_position_string()
261
+ id = f"{page_number:04d}{position}"
262
+ content = link.get_content()
263
+ if content is not None:
264
+ builder.append(f"LINKO_{id}", content, allow_duplicate=True)
265
+ link_metadata = link.metadata
266
+ address_list = builder.get_duplicate_block_address_list(f"LINKO_{id}")
267
+ if len(address_list) == 1:
268
+ link_metadata["LINKBITMAP"] = str(address_list[0])
269
+ else:
270
+ link_metadata["LINKBITMAP"] = str(address_list[-1]) # use last address
271
+ link_metadata_block = _construct_metadata_block(link_metadata)
272
+ builder.append(
273
+ f"LINKO_{id}/metadata", link_metadata_block, allow_duplicate=True
274
+ )
275
+
276
+
277
+ def _pack_backgrounds(builder, notebook):
278
+ for i in range(notebook.get_total_pages()):
279
+ page = notebook.get_page(i)
280
+ style = page.get_style()
281
+ if style.startswith("user_"):
282
+ style += page.get_style_hash()
283
+ content = _find_background_content_from_page(page)
284
+ if content is not None:
285
+ builder.append(f"STYLE_{style}", content)
286
+
287
+
288
+ def _pack_pages(builder, notebook, offset=0):
289
+ for i in range(notebook.get_total_pages()):
290
+ page_number = i + 1 + offset
291
+ page = notebook.get_page(i)
292
+ page = utils.WorkaroundPageWrapper.from_page(page)
293
+ # layers
294
+ layers = page.get_layers()
295
+ for layer in layers:
296
+ layer_name = layer.get_name()
297
+ if layer_name is None:
298
+ continue
299
+ if layer_name == "BGLAYER":
300
+ style = page.get_style()
301
+ if style.startswith("user_"):
302
+ style += page.get_style_hash()
303
+ layer_metadata = layer.metadata
304
+ layer_metadata["LAYERNAME"] = layer_name
305
+ layer_metadata["LAYERBITMAP"] = str(
306
+ builder.get_block_address(f"STYLE_{style}")
307
+ )
308
+ layer_metadata_block = _construct_metadata_block(layer_metadata)
309
+ builder.append(
310
+ f"PAGE{page_number}/{layer_name}/metadata", layer_metadata_block
311
+ )
312
+ else:
313
+ content = layer.get_content()
314
+ builder.append(f"PAGE{page_number}/{layer_name}/LAYERBITMAP", content)
315
+ layer_metadata = layer.metadata
316
+ layer_metadata["LAYERNAME"] = layer_name
317
+ layer_metadata["LAYERBITMAP"] = str(
318
+ builder.get_block_address(
319
+ f"PAGE{page_number}/{layer_name}/LAYERBITMAP"
320
+ )
321
+ )
322
+ layer_metadata_block = _construct_metadata_block(layer_metadata)
323
+ builder.append(
324
+ f"PAGE{page_number}/{layer_name}/metadata", layer_metadata_block
325
+ )
326
+ # totalpath
327
+ totalpath_block = page.get_totalpath()
328
+ if totalpath_block is not None:
329
+ builder.append(f"PAGE{page_number}/TOTALPATH", totalpath_block)
330
+ # page metadata
331
+ page_metadata = page.metadata
332
+ del page_metadata["__layers__"]
333
+ for prop in ["MAINLAYER", "LAYER1", "LAYER2", "LAYER3", "BGLAYER"]:
334
+ address = builder.get_block_address(f"PAGE{page_number}/{prop}/metadata")
335
+ page_metadata[prop] = address
336
+ page_metadata["TOTALPATH"] = builder.get_block_address(
337
+ f"PAGE{page_number}/TOTALPATH"
338
+ )
339
+ page_metadata_block = _construct_metadata_block(page_metadata)
340
+ builder.append(f"PAGE{page_number}/metadata", page_metadata_block)
341
+
342
+
343
+ def _pack_footer(builder):
344
+ metadata_footer = {}
345
+ metadata_footer.setdefault("FILE_FEATURE", builder.get_block_address("__header__"))
346
+ for label in builder.get_labels():
347
+ if re.match(r"PAGE\d+/metadata", label):
348
+ address = builder.get_block_address(label)
349
+ label = label[: -len("/metadata")]
350
+ metadata_footer.setdefault(label, address)
351
+ for label in builder.get_labels():
352
+ if re.match(r"TITLE_\d+/metadata", label):
353
+ address_list = builder.get_duplicate_block_address_list(label)
354
+ label = label[: -len("/metadata")]
355
+ if len(address_list) == 1:
356
+ metadata_footer.setdefault(label, address_list[0])
357
+ else:
358
+ metadata_footer[label] = address_list
359
+ for label in builder.get_labels():
360
+ if re.match(r"KEYWORD_\d+/metadata", label):
361
+ address_list = builder.get_duplicate_block_address_list(label)
362
+ label = label[: -len("/metadata")]
363
+ if len(address_list) == 1:
364
+ metadata_footer.setdefault(label, address_list[0])
365
+ else:
366
+ metadata_footer[label] = address_list
367
+ for label in builder.get_labels():
368
+ if re.match(r"LINKO_\d+/metadata", label):
369
+ address_list = builder.get_duplicate_block_address_list(label)
370
+ label = label[: -len("/metadata")]
371
+ if len(address_list) == 1:
372
+ metadata_footer.setdefault(label, address_list[0])
373
+ else:
374
+ metadata_footer[label] = address_list
375
+ address = builder.get_block_address("COVER_2")
376
+ if address == 0:
377
+ metadata_footer["COVER_0"] = 0
378
+ else:
379
+ metadata_footer["COVER_2"] = address
380
+ for label in builder.get_labels():
381
+ if label.startswith("STYLE_"):
382
+ address = builder.get_block_address(label)
383
+ metadata_footer.setdefault(label, address)
384
+ footer_block = _construct_metadata_block(metadata_footer)
385
+ builder.append("__footer__", footer_block)
386
+
387
+
388
+ def _pack_tail(builder):
389
+ builder.append("__tail__", b"tail", skip_block_size=True)
390
+
391
+
392
+ def _pack_footer_address(builder):
393
+ footer_address = builder.get_block_address("__footer__")
394
+ builder.append(
395
+ "__footer_address__", footer_address.to_bytes(4, "little"), skip_block_size=True
396
+ )
397
+
398
+
399
+ def _verify_header_property(prop_name, metadata1, metadata2):
400
+ if metadata1.header.get(prop_name) != metadata2.header.get(prop_name):
401
+ raise ValueError(f"<{prop_name}> property must be same between merging files")
402
+
403
+
404
+ def _construct_metadata_block(info):
405
+ block_data = ""
406
+ for k, v in info.items():
407
+ if type(v) is list:
408
+ for e in v:
409
+ block_data += f"<{k}:{e}>"
410
+ else:
411
+ block_data += f"<{k}:{v}>"
412
+ return block_data.encode("utf-8")
413
+
414
+
415
+ def _find_background_content_from_page(page):
416
+ page = utils.WorkaroundPageWrapper.from_page(page)
417
+ if not page.is_layer_supported():
418
+ return None
419
+ layers = page.get_layers()
420
+ for layer in layers:
421
+ if layer.get_name() == "BGLAYER":
422
+ return layer.get_content()
423
+ return None