nbk2pdf 1.0.0

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,109 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: 2026 Sergey Avseyev
3
+ # frozen_string_literal: true
4
+
5
+ module KindleNotebook
6
+ # Reads the Kindle's reader data store, the format of the `.yjr` and `.yjf`
7
+ # files it keeps beside a document for reading state and annotations.
8
+ #
9
+ # signature 8 bytes
10
+ # version long
11
+ # count int, the number of top-level fields
12
+ # fields 0xFE, name, then typed values and nested fields, then 0xFF
13
+ #
14
+ # Every value carries its own type byte, so the file reads without knowing
15
+ # what any field means. Names are strings without the type byte.
16
+ module Krds
17
+ SIGNATURE = "\x00\x00\x00\x00\x00\x1A\xB1\x26".b
18
+
19
+ BOOLEAN = 0
20
+ INT = 1
21
+ LONG = 2
22
+ UTF = 3
23
+ BYTE = 7
24
+ FIELD_BEGIN = 0xFE
25
+ FIELD_END = 0xFF
26
+
27
+ # A string whose flag byte is set has no length and no content.
28
+ NULL_STRING = 1
29
+
30
+ Field = Struct.new(:name, :items) do
31
+ # Every field called +name+, at any depth below this one.
32
+ def find_all(name)
33
+ items.grep(Field).flat_map { |field| (field.name == name ? [field] : []) + field.find_all(name) }
34
+ end
35
+
36
+ def strings = items.grep(String)
37
+ end
38
+
39
+ class FormatError < Error; end
40
+
41
+ module_function
42
+
43
+ # Returns the top-level fields of +data+ as one root Field.
44
+ def parse(data)
45
+ reader = Reader.new(data.b)
46
+ raise FormatError, "not a Kindle reader data file" unless reader.take(SIGNATURE.bytesize) == SIGNATURE
47
+
48
+ reader.value
49
+ count = reader.value
50
+ raise FormatError, "field count missing" unless count.is_a?(Integer)
51
+
52
+ Field.new("", Array.new(count) { reader.value })
53
+ end
54
+
55
+ def parse_file(path) = parse(File.binread(path))
56
+
57
+ # A cursor over the bytes; every call consumes what it returns.
58
+ class Reader
59
+ def initialize(data)
60
+ @data = data
61
+ @offset = 0
62
+ end
63
+
64
+ def take(size)
65
+ raise FormatError, "unexpected end of data at #{@offset}" if @offset + size > @data.bytesize
66
+
67
+ chunk = @data.byteslice(@offset, size)
68
+ @offset += size
69
+ chunk
70
+ end
71
+
72
+ def byte = take(1).getbyte(0)
73
+
74
+ # One value of any type, or a whole field with everything inside it.
75
+ def value
76
+ type = byte
77
+ case type
78
+ when BOOLEAN then byte != 0
79
+ when INT then take(4).unpack1("l>")
80
+ when LONG then take(8).unpack1("q>")
81
+ when UTF then string
82
+ when BYTE then byte
83
+ when FIELD_BEGIN then field
84
+ else raise FormatError, format("unknown value type 0x%<type>02X at %<at>d", type: type, at: @offset - 1)
85
+ end
86
+ end
87
+
88
+ def string
89
+ return nil if byte == NULL_STRING
90
+
91
+ take(take(2).unpack1("n")).force_encoding(Encoding::UTF_8)
92
+ end
93
+
94
+ def field
95
+ name = string
96
+ items = []
97
+ items << value until peek == FIELD_END
98
+ @offset += 1
99
+ Field.new(name, items)
100
+ end
101
+
102
+ def peek
103
+ raise FormatError, "unterminated field at #{@offset}" if @offset >= @data.bytesize
104
+
105
+ @data.getbyte(@offset)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,442 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: 2026 Sergey Avseyev
3
+ # frozen_string_literal: true
4
+
5
+ require "set"
6
+ require "sqlite3"
7
+ require "tmpdir"
8
+
9
+ module KindleNotebook
10
+ # PDF user space is measured in points.
11
+ POINTS_PER_INCH = 72.0
12
+
13
+ # The brush the firmware records for the highlighter pen.
14
+ HIGHLIGHTER_BRUSH = 1
15
+
16
+ # A single pen stroke in absolute canvas units. +pressures+ is one reading
17
+ # per point, or nil when the stroke did not record any.
18
+ Stroke = Struct.new(:xs, :ys, :pressures, :thickness, :color, :brush_type, keyword_init: true) do
19
+ def dot? = xs.size < 2
20
+ def highlighter? = brush_type == HIGHLIGHTER_BRUSH
21
+ end
22
+
23
+ # One drawing primitive of a page template, in template units.
24
+ TemplateShape = Struct.new(:segments, :color, :width, keyword_init: true)
25
+
26
+ # The ruled/grid background a page was written on.
27
+ Template = Struct.new(:id, :kind, :width, :height, :shapes, keyword_init: true) do
28
+ def empty? = shapes.nil? || shapes.empty?
29
+ end
30
+
31
+ # Where a page's canvas sits on a document page, as fractions of that page's
32
+ # width and height measured from its top left corner.
33
+ Frame = Struct.new(:left, :top, :width, :height, keyword_init: true)
34
+
35
+ # One notebook page: a canvas, its background template and its ink. Ink
36
+ # written on a document also carries the +frame+ it was written in.
37
+ Page = Struct.new(:id, :canvas_width, :canvas_height, :ppi, :template, :strokes, :frame,
38
+ keyword_init: true) do
39
+ def width_in_points = canvas_width / ppi * POINTS_PER_INCH
40
+ def height_in_points = canvas_height / ppi * POINTS_PER_INCH
41
+ end
42
+
43
+ # Reads a Kindle Scribe `nbk` file: unwraps the signature container, pulls the
44
+ # Ion fragments out of the embedded SQLite database and resolves them into
45
+ # pages, strokes and templates.
46
+ class Notebook
47
+ # Fragment types, named the way Amazon's own tooling renders them.
48
+ READING_ORDER = "$258"
49
+ DOCUMENT_DATA = "$538"
50
+ BOOK_METADATA = "$490"
51
+ PAGE_LIST = "$351"
52
+
53
+ # Field names inside those fragments.
54
+ SECTIONS = "$169"
55
+ SECTION_NAME = "$178"
56
+ SECTION_MEMBERS = "$170"
57
+ CONTENT = "$141"
58
+ CHILDREN = "$146"
59
+ REFERENCE = "$176"
60
+ SHAPES = "$250"
61
+ PATH_OPS = "$249"
62
+ SHAPE_COLOR = "$75"
63
+ SHAPE_WIDTH = "$76"
64
+ DIM_WIDTH = "$56"
65
+ DIM_HEIGHT = "$57"
66
+ DIM_VALUE = "$307"
67
+ DIM_UNIT = "$306"
68
+ PERCENT = "$314"
69
+ FRAME_LEFT = "$59"
70
+ FRAME_TOP = "$58"
71
+ METADATA_CATEGORIES = "$491"
72
+ CATEGORY_NAME = "$495"
73
+ CATEGORY_ENTRIES = "$258"
74
+ ENTRY_KEY = "$492"
75
+ ENTRY_VALUE = "$307"
76
+
77
+ DEFAULT_CANVAS_WIDTH = 15_624
78
+ DEFAULT_CANVAS_HEIGHT = 20_832
79
+ DEFAULT_PPI = 2520.0
80
+ DEFAULT_THICKNESS = 23.625
81
+ DEFAULT_BRUSH = 7
82
+
83
+ SYMBOL_TABLE_ID = "$ion_symbol_table"
84
+ TITLE_CATEGORY = "kindle_title_metadata"
85
+
86
+ # What each tree walk is looking for.
87
+ STROKE_LEAF = ->(node) { node["nmdl.type"] == "nmdl.stroke" || node.key?("nmdl.stroke_points") }
88
+ TEMPLATE_LEAF = ->(node) { node.key?(SHAPES) }
89
+ private_constant :STROKE_LEAF, :TEMPLATE_LEAF
90
+
91
+ class MalformedError < Error; end
92
+
93
+ attr_reader :path, :uuid
94
+
95
+ # Ink that could not be decoded, reported rather than silently dropped.
96
+ attr_reader :warnings
97
+
98
+ def self.open(path) = new(path)
99
+
100
+ # Finds every notebook under +root+ (or returns the notebook at +root+).
101
+ def self.find(root)
102
+ root = File.expand_path(root)
103
+ return [root] if File.file?(root)
104
+
105
+ direct = File.join(root, "nbk")
106
+ return [direct] if File.file?(direct)
107
+
108
+ Dir.glob(File.join(root, "**", "nbk")).select { |p| File.file?(p) }.sort
109
+ end
110
+
111
+ def initialize(path)
112
+ @path = File.expand_path(path)
113
+ @path = File.join(@path, "nbk") if File.directory?(@path)
114
+ raise Error, "no such notebook: #{path}" unless File.file?(@path)
115
+
116
+ @uuid = File.basename(File.dirname(@path))
117
+ @warnings = []
118
+ @fragments = load_fragments
119
+ @templates = {}
120
+ end
121
+
122
+ def modified_at = File.mtime(@path)
123
+
124
+ # The device-supplied title, when the notebook carries one. Standalone
125
+ # notebooks generally do not -- the Scribe keeps their names elsewhere.
126
+ def title = title_metadata["title"]
127
+
128
+ def book_id = title_metadata["book_id"]
129
+
130
+ def pages
131
+ @pages ||= page_ids.filter_map { |id| build_page(id) }
132
+ end
133
+
134
+ # The file name this notebook should be exported under, without extension.
135
+ #
136
+ # 1. an explicit override from +titles+ (keyed by uuid or book id)
137
+ # 2. the title stored in the notebook itself
138
+ # 3. the modification date followed by the notebook's uuid
139
+ def basename(titles: {})
140
+ name = known_name(titles)
141
+ return sanitize(name) if name
142
+
143
+ sanitize("#{modified_at.strftime("%Y-%m-%d")}_#{uuid}")
144
+ end
145
+
146
+ # The name this notebook is known by, or nil when it falls back to its uuid.
147
+ def known_name(titles = {})
148
+ [titles[uuid], titles[book_id], title].find { |name| present?(name) }
149
+ end
150
+
151
+ ILLEGAL_NAME_CHARS = %r{[\\/:*?"<>|\x00-\x1f]}
152
+
153
+ # Makes +name+ usable as a file name, falling back to the notebook's uuid
154
+ # when nothing recognisable survives.
155
+ def sanitize(name, limit: 120) = self.class.safe_name(name, limit: limit) || uuid
156
+
157
+ # +name+ made usable as a file name, or nil when nothing recognisable is left.
158
+ def self.safe_name(name, limit: 120)
159
+ safe = name.to_s.gsub(ILLEGAL_NAME_CHARS, "_").strip.sub(/[. ]+\z/, "")
160
+ safe = safe[0, limit].to_s.strip
161
+ safe.match?(/[[:alnum:]]/) ? safe : nil
162
+ end
163
+
164
+ private
165
+
166
+ def present?(value) = !value.nil? && !value.to_s.strip.empty?
167
+
168
+ def load_fragments
169
+ raw = Dir.mktmpdir("nbk") do |dir|
170
+ database = File.join(dir, "nbk.sqlite")
171
+ Container.unwrap_file(@path, database)
172
+ read_rows(database)
173
+ end
174
+
175
+ symbols = raw[SYMBOL_TABLE_ID]
176
+ symbols = symbols ? Ion::SymbolTable.from_fragment(symbols) : Ion::SymbolTable.system
177
+ raw.delete(SYMBOL_TABLE_ID)
178
+ raw.transform_values { |blob| Ion.parse(blob, symbols: symbols).last }
179
+ end
180
+
181
+ def read_rows(database)
182
+ rows = {}
183
+ SQLite3::Database.new(database, readonly: true) do |db|
184
+ db.execute("SELECT id, payload_value FROM fragments") do |id, blob|
185
+ rows[id] = blob.to_s.b
186
+ end
187
+ db.execute("SELECT id, payload_value, deleted FROM local_delta_fragments") do |id, blob, deleted|
188
+ deleted.to_i.zero? ? rows[id] = blob.to_s.b : rows.delete(id)
189
+ end
190
+ end
191
+ rows
192
+ rescue SQLite3::Exception => e
193
+ raise MalformedError, "cannot read notebook database: #{e.message}"
194
+ end
195
+
196
+ def body(id) = id && Ion.unwrap(@fragments[Ion.unwrap(id)])
197
+
198
+ def fragments_of_type(type)
199
+ @fragments.each_value.select { |value| value.is_a?(Ion::Annotated) && value.type == type }
200
+ end
201
+
202
+ def title_metadata
203
+ @title_metadata ||= begin
204
+ metadata = Ion.unwrap(fragments_of_type(BOOK_METADATA).first) || {}
205
+ categories = Array(metadata[METADATA_CATEGORIES]).map { |category| Ion.unwrap(category) }
206
+ category = categories.find { |c| Ion.unwrap(c[CATEGORY_NAME]) == TITLE_CATEGORY }
207
+ Array(category&.[](CATEGORY_ENTRIES)).each_with_object({}) do |entry, acc|
208
+ entry = Ion.unwrap(entry)
209
+ acc[Ion.unwrap(entry[ENTRY_KEY]).to_s] = Ion.unwrap(entry[ENTRY_VALUE])
210
+ end
211
+ end
212
+ end
213
+
214
+ def reading_order
215
+ @reading_order ||= Ion.unwrap(fragments_of_type(READING_ORDER).first) ||
216
+ raise(MalformedError, "notebook has no reading order")
217
+ end
218
+
219
+ def document_data
220
+ @document_data ||= Ion.unwrap(fragments_of_type(DOCUMENT_DATA).first) || {}
221
+ end
222
+
223
+ def sections_of(source) = Array(source[SECTIONS]).map { |section| Ion.unwrap(section) }
224
+
225
+ def section_of(source, name) = sections_of(source).find { |s| Ion.unwrap(s[SECTION_NAME]) == name }
226
+
227
+ def members_of(section) = Array(section&.[](SECTION_MEMBERS)).map { |ref| Ion.unwrap(ref) }
228
+
229
+ def page_ids
230
+ members_of(section_of(reading_order, PAGE_LIST) || sections_of(reading_order).first)
231
+ end
232
+
233
+ def build_page(id)
234
+ page = body(id)
235
+ return nil unless page.is_a?(Hash)
236
+
237
+ Page.new(
238
+ id: id,
239
+ canvas_width: (page["nmdl.canvas_width"] || DEFAULT_CANVAS_WIDTH).to_i,
240
+ canvas_height: (page["nmdl.canvas_height"] || DEFAULT_CANVAS_HEIGHT).to_i,
241
+ ppi: (page["nmdl.normalized_ppi"] || DEFAULT_PPI).to_f,
242
+ template: template_for(page),
243
+ strokes: collect_strokes(page),
244
+ frame: frame_of(page)
245
+ )
246
+ end
247
+
248
+ # A page written on a document records the box its canvas occupies there,
249
+ # in percent of the document page; a notebook page records none.
250
+ def frame_of(page)
251
+ box = [FRAME_LEFT, FRAME_TOP, DIM_WIDTH, DIM_HEIGHT].map { |key| percentage(page[key]) }
252
+ return nil if box.any?(&:nil?)
253
+
254
+ left, top, width, height = box
255
+ Frame.new(left: left, top: top, width: width, height: height)
256
+ end
257
+
258
+ def percentage(value)
259
+ value = Ion.unwrap(value)
260
+ return nil unless value.is_a?(Hash) && Ion.unwrap(value[DIM_UNIT]) == PERCENT
261
+
262
+ Ion.unwrap(value[DIM_VALUE]).to_f / 100
263
+ end
264
+
265
+ def collect_strokes(page)
266
+ each_leaf(page[CONTENT], STROKE_LEAF).filter_map { |node| build_stroke(node) }
267
+ end
268
+
269
+ # Walks a content tree depth first -- so ink keeps its drawing order --
270
+ # following both fragment references and inline children, and returns every
271
+ # node +leaf+ recognises. A fragment is visited once, however often it is
272
+ # referenced.
273
+ def each_leaf(roots, leaf)
274
+ found = []
275
+ visited = Set.new
276
+ stack = Array(roots).dup
277
+
278
+ until stack.empty?
279
+ node = Ion.unwrap(stack.shift)
280
+ next unless node.is_a?(Hash)
281
+ next found << node if leaf.call(node)
282
+
283
+ stack.unshift(*children_of(node, visited))
284
+ end
285
+ found
286
+ end
287
+
288
+ def children_of(node, visited)
289
+ reference = Ion.unwrap(node[REFERENCE])
290
+ referenced = reference && visited.add?(reference) ? Array(body(reference)&.[](CHILDREN)) : []
291
+ referenced + Array(node[CONTENT]) + Array(node[CHILDREN])
292
+ end
293
+
294
+ def build_stroke(node)
295
+ points = Ion.unwrap(node["nmdl.stroke_points"])
296
+ count = points&.[]("nmdl.num_points").to_i
297
+ return nil if count < 1
298
+
299
+ origin = Array(Ion.unwrap(node["nmdl.stroke_bounds"]))
300
+ Stroke.new(
301
+ xs: coordinates(points["nmdl.position_x"], count, origin[0]),
302
+ ys: coordinates(points["nmdl.position_y"], count, origin[1]),
303
+ pressures: pressures(points["nmdl.pressure"], count),
304
+ **pen(node)
305
+ )
306
+ rescue StrokeDecoder::DecodeError => e
307
+ @warnings << "stroke #{Ion.unwrap(node["$598"]) || "?"}: #{e.message}"
308
+ nil
309
+ end
310
+
311
+ def pen(node)
312
+ {
313
+ thickness: (node["nmdl.thickness"] || DEFAULT_THICKNESS).to_f,
314
+ color: (node["nmdl.color"] || 0).to_i,
315
+ brush_type: (node["nmdl.brush_type"] || DEFAULT_BRUSH).to_i
316
+ }
317
+ end
318
+
319
+ # Unreadable pressure costs the stroke its taper, not the stroke itself.
320
+ def pressures(blob, count)
321
+ return nil unless blob
322
+
323
+ StrokeDecoder.decode(blob, count)
324
+ rescue StrokeDecoder::DecodeError
325
+ nil
326
+ end
327
+
328
+ # Decoded coordinates are relative to the stroke's bounding box origin.
329
+ def coordinates(blob, count, origin)
330
+ offset = origin.to_i
331
+ StrokeDecoder.decode(blob, count).map { |value| value + offset }
332
+ end
333
+
334
+ # A page names its template, or stores a placeholder symbol to say it has
335
+ # none; a page that names nothing at all takes the document's default.
336
+ def template_for(page)
337
+ id =
338
+ if page.key?("nmdl.template_id")
339
+ Ion.unwrap(page["nmdl.template_id"])
340
+ else
341
+ default_template_id
342
+ end
343
+ return nil unless @fragments.key?(id)
344
+
345
+ @templates.fetch(id) { @templates[id] = build_template(id) }
346
+ end
347
+
348
+ # The template a page without its own falls back to.
349
+ def default_template_id
350
+ @default_template_id ||= Ion.unwrap(document_data["nmdl.template_id"])
351
+ end
352
+
353
+ def build_template(id)
354
+ root = body(id)
355
+ return nil unless root.is_a?(Hash)
356
+
357
+ canvas = each_leaf([root], TEMPLATE_LEAF).first
358
+ return nil unless canvas
359
+
360
+ Template.new(
361
+ id: id,
362
+ kind: Ion.unwrap(root["nmdl.template_type"]),
363
+ width: dimension(canvas[DIM_WIDTH]),
364
+ height: dimension(canvas[DIM_HEIGHT]),
365
+ shapes: Array(canvas[SHAPES]).filter_map { |shape| build_shape(shape) }
366
+ )
367
+ end
368
+
369
+ def dimension(value)
370
+ value = Ion.unwrap(value)
371
+ return value.to_f if value.is_a?(Numeric)
372
+
373
+ value.is_a?(Hash) ? Ion.unwrap(value[DIM_VALUE]).to_f : 0.0
374
+ end
375
+
376
+ def build_shape(shape)
377
+ shape = Ion.unwrap(shape)
378
+ segments = PathOps.segments(Array(Ion.unwrap(shape[PATH_OPS])))
379
+ return nil if segments.empty?
380
+
381
+ TemplateShape.new(
382
+ segments: segments,
383
+ color: (shape[SHAPE_COLOR] || 0xFF00_0000).to_i,
384
+ width: (shape[SHAPE_WIDTH] || 1).to_f
385
+ )
386
+ end
387
+
388
+ # Template outlines are flat op streams: an opcode followed by its operands.
389
+ # These are the only opcodes the device writes. An unknown one ends the
390
+ # shape, because guessing how many operands it takes would desynchronise
391
+ # everything after it.
392
+ module PathOps
393
+ MOVE_TO = 0
394
+ LINE_TO = 1
395
+ CLOSE = 4
396
+
397
+ ARITY = { MOVE_TO => 2, LINE_TO => 2, CLOSE => 0 }.freeze
398
+
399
+ module_function
400
+
401
+ # Returns a list of polylines, each a list of [x, y] pairs. A run with no
402
+ # starting point, or with only one, draws nothing and is dropped, as does
403
+ # the whole shape when the op stream cannot be read to the end.
404
+ def segments(ops)
405
+ parsed = parse(ops)
406
+ return [] if parsed.nil?
407
+
408
+ parsed.chunk_while { |_, (opcode, _)| opcode != MOVE_TO }
409
+ .map { |run| outline(run) }
410
+ .reject { |line| line.size < 2 }
411
+ end
412
+
413
+ # One run of ops, starting at a move, becomes one polyline.
414
+ def outline(run)
415
+ points = []
416
+ run.each do |opcode, args|
417
+ case opcode
418
+ when MOVE_TO, LINE_TO then points << args
419
+ when CLOSE then points << points.first if points.size > 1
420
+ end
421
+ end
422
+ points
423
+ end
424
+
425
+ # Splits the stream into [opcode, operands] pairs, or nil on an opcode it
426
+ # does not know or operands that run off the end.
427
+ def parse(ops)
428
+ parsed = []
429
+ index = 0
430
+ while index < ops.size
431
+ opcode = ops[index].to_i
432
+ arity = ARITY[opcode]
433
+ return nil if arity.nil? || index + arity >= ops.size
434
+
435
+ parsed << [opcode, ops[index + 1, arity].map(&:to_f)]
436
+ index += 1 + arity
437
+ end
438
+ parsed
439
+ end
440
+ end
441
+ end
442
+ end