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,133 @@
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
+ # A PDF on the Kindle and the handwriting written on it.
7
+ #
8
+ # The device never changes the PDF. Opening it writes a sidecar next to it,
9
+ # and writing on it keeps the ink in a notebook of its own:
10
+ #
11
+ # documents/.../<name>.pdf
12
+ # documents/.../<name>.sdr/<id>/book.sdr/*.yjr one note per page written on
13
+ # .notebooks/<id>!!PDOC!!notebook/nbk the ink, one page per note
14
+ #
15
+ # Each note in the `.yjr` gives a position on the PDF and the notebook page
16
+ # holding the ink.
17
+ class Document
18
+ # Ink for one PDF page: +page_index+ counts from zero, +page+ is the
19
+ # notebook page carrying the strokes and the frame they were written in.
20
+ Note = Struct.new(:page_index, :page, keyword_init: true)
21
+
22
+ NOTEBOOKS = ".notebooks"
23
+ ANNOTATIONS = "*/book.sdr/*.yjr"
24
+ HANDWRITTEN_NOTE = "annotation.personal.handwritten_note"
25
+
26
+ # A position reads like `AckEAAAAAAAA:65538`. The number after the colon
27
+ # holds the page index in its upper 16 bits; the lower 16 were 2 on every
28
+ # page seen. What the base64 part encodes is not known, and it is not used.
29
+ PAGE_SHIFT = 16
30
+ POSITION_NUMBER = /:(\d+)\z/
31
+
32
+ attr_reader :path, :id, :notebook_path
33
+
34
+ # Ink that could not be placed or decoded.
35
+ attr_reader :warnings
36
+
37
+ def self.open(path) = new(path)
38
+
39
+ # The PDF a device notebook holds ink for, or nil for an ordinary notebook
40
+ # or one whose document is not a PDF on the device.
41
+ def self.pdf_for(notebook_path)
42
+ folder = File.basename(File.dirname(notebook_path))
43
+ id, kind = folder.split("!!", 2)
44
+ return nil unless kind
45
+
46
+ storage = File.dirname(notebook_path, 3)
47
+ Dir.glob("documents/**/*.sdr/#{id}", base: storage).each do |sidecar|
48
+ stem = File.join(storage, File.dirname(sidecar).delete_suffix(".sdr"))
49
+ pdf = ["#{stem}.pdf", "#{stem}.PDF"].find { |candidate| File.file?(candidate) }
50
+ return pdf if pdf
51
+ end
52
+ nil
53
+ end
54
+
55
+ def initialize(path)
56
+ @path = File.expand_path(path)
57
+ raise Error, "no such document: #{path}" unless File.file?(@path)
58
+
59
+ @sidecar = sidecar
60
+ @id = File.basename(@sidecar)
61
+ @notebook_path = find_notebook
62
+ @warnings = []
63
+ end
64
+
65
+ def notes
66
+ @notes ||= place(Notebook.open(@notebook_path))
67
+ end
68
+
69
+ private
70
+
71
+ # The folder holding the notes. The device also writes a `kpp_` copy of it
72
+ # without any, which the glob leaves out.
73
+ def sidecar
74
+ root = @path.sub(/\.pdf\z/i, ".sdr")
75
+ raise Error, "#{@path} is not a PDF" if root == @path
76
+
77
+ notes = Dir.glob(ANNOTATIONS, base: root)
78
+ raise Error, "#{@path} has not been opened on the Kindle: no #{File.basename(root)} beside it" if notes.empty?
79
+
80
+ File.join(root, notes.max_by { |file| File.mtime(File.join(root, file)) }.split("/").first)
81
+ end
82
+
83
+ # The ink sits in the storage root's notebook folder, however deep the
84
+ # document is filed.
85
+ def find_notebook
86
+ storage = File.dirname(@sidecar)
87
+ storage = File.dirname(storage) until File.directory?(File.join(storage, NOTEBOOKS)) || root?(storage)
88
+ found = Dir.glob("#{NOTEBOOKS}/#{@id}!!*/nbk", base: storage).first
89
+ raise Error, "no handwriting for #{@path}: #{NOTEBOOKS}/#{@id}!!...!!notebook not found" unless found
90
+
91
+ File.join(storage, found)
92
+ end
93
+
94
+ def root?(dir) = File.dirname(dir) == dir
95
+
96
+ def place(notebook)
97
+ @warnings.concat(notebook.warnings)
98
+ pages = notebook.pages.to_h { |page| [page.id, page] }
99
+ placed = []
100
+
101
+ positions.each do |position, note_id|
102
+ page = pages.delete(note_id)
103
+ index = page_index(position)
104
+ problem = placement_problem(page, index, position)
105
+ problem ? @warnings << "note #{note_id}: #{problem}" : placed << Note.new(page_index: index, page: page)
106
+ end
107
+ pages.each_key { |note_id| @warnings << "note #{note_id}: not attached to any page of the document" }
108
+ placed.sort_by(&:page_index)
109
+ end
110
+
111
+ def placement_problem(page, index, position)
112
+ if page.nil? then "its ink is missing from #{@notebook_path}"
113
+ elsif index.nil? then "position #{position} names no page"
114
+ elsif page.frame.nil? then "page #{index + 1} has no frame to place it in"
115
+ end
116
+ end
117
+
118
+ # [position, notebook page id] for every handwritten note in the sidecar.
119
+ def positions
120
+ Dir.glob("book.sdr/*.yjr", base: @sidecar).flat_map do |file|
121
+ Krds.parse_file(File.join(@sidecar, file)).find_all(HANDWRITTEN_NOTE).map do |note|
122
+ strings = note.strings
123
+ [strings.first, strings.last]
124
+ end
125
+ end
126
+ end
127
+
128
+ def page_index(position)
129
+ number = position.to_s[POSITION_NUMBER, 1]
130
+ number && (number.to_i >> PAGE_SHIFT)
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,247 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: 2026 Sergey Avseyev
3
+ # frozen_string_literal: true
4
+
5
+ require "combine_pdf"
6
+ require "prawn"
7
+
8
+ module KindleNotebook
9
+ # Writes a copy of a PDF with the handwriting the Kindle holds for it.
10
+ #
11
+ # By default the ink is drawn into each page over what is already there, so
12
+ # every viewer and printer shows it. With +annotations+ each stroke becomes
13
+ # a PDF ink annotation instead, which a viewer can hide, move or delete. Its
14
+ # appearance is drawn by the same code as the page, so it keeps the taper.
15
+ class DocumentRenderer
16
+ # Annotation flag: print the annotation with the page.
17
+ PRINT = 4
18
+
19
+ def initialize(annotations: false)
20
+ @annotations = annotations
21
+ @renderer = PdfRenderer.new(render_template: false, paper: nil)
22
+ end
23
+
24
+ def render(document, destination)
25
+ pdf = load(document.path)
26
+ notes = on_pages(document, pdf.pages.size).reject { |note| note.page.strokes.empty? }
27
+ if notes.empty? && document.warnings.any?
28
+ raise Error, "none of the ink could be placed on the PDF: #{document.warnings.join("; ")}"
29
+ end
30
+
31
+ if @annotations
32
+ annotate(pdf, notes)
33
+ elsif notes.any?
34
+ stamp(pdf, notes)
35
+ end
36
+ File.binwrite(destination, pdf.to_pdf)
37
+ notes
38
+ end
39
+
40
+ private
41
+
42
+ def load(path)
43
+ CombinePDF.load(path, allow_optional_content: true)
44
+ rescue CombinePDF::ParsingError, CombinePDF::EncryptionError => e
45
+ raise Error, "cannot read #{path}: #{e.message}"
46
+ end
47
+
48
+ def on_pages(document, count)
49
+ beyond, within = document.notes.partition { |note| note.page_index >= count }
50
+ beyond.each do |note|
51
+ document.warnings << "note #{note.page.id}: ink for page #{note.page_index + 1}, " \
52
+ "but the PDF has #{count}; is it the same file?"
53
+ end
54
+ within
55
+ end
56
+
57
+ # The ink is drawn into a document of its own, one page per note, and each
58
+ # of its pages is then laid over the page the note belongs to.
59
+ def stamp(pdf, notes)
60
+ overlay = Prawn::Document.new(skip_page_creation: true, margin: 0)
61
+ notes.each { |note| draw_overlay(overlay, pdf.pages[note.page_index], note.page) }
62
+ ink = CombinePDF.parse(overlay.render)
63
+ notes.each_with_index { |note, index| pdf.pages[note.page_index] << ink.pages[index] }
64
+ end
65
+
66
+ def draw_overlay(overlay, target, page)
67
+ overlay.start_new_page(size: box(target).last(2), margin: 0)
68
+ overlay.cap_style(:round)
69
+ overlay.join_style(:round)
70
+ overlay.transformation_matrix(*placement(target, page)) do
71
+ page.strokes.each { |stroke| @renderer.draw_stroke(overlay, page, stroke) }
72
+ end
73
+ end
74
+
75
+ def annotate(pdf, notes)
76
+ notes.each do |note|
77
+ target = pdf.pages[note.page_index]
78
+ matrix = placement(target, note.page)
79
+ added = note.page.strokes.map { |stroke| indirect(ink_annotation(note.page, stroke, matrix)) }
80
+ existing = target[:Annots]
81
+ existing = existing[:referenced_object] if existing.is_a?(Hash)
82
+ target[:Annots] = (existing.is_a?(Array) ? existing : []) + added
83
+ end
84
+ end
85
+
86
+ def ink_annotation(page, stroke, matrix)
87
+ appearance = Appearance.new(matrix)
88
+ @renderer.draw_stroke(appearance, page, stroke)
89
+ rect = appearance.bounds
90
+ {
91
+ Type: :Annot,
92
+ Subtype: :Ink,
93
+ F: PRINT,
94
+ Rect: rect,
95
+ InkList: [appearance.ink_list],
96
+ C: appearance.color,
97
+ BS: { W: appearance.width },
98
+ AP: { N: indirect(appearance.form(rect)) }
99
+ }
100
+ end
101
+
102
+ def indirect(object) = { is_reference_only: true, referenced_object: object }
103
+
104
+ # [x, y, width, height] of the visible page, in its own user space.
105
+ def box(pdf_page)
106
+ left, bottom, right, top = pdf_page.page_size.map(&:to_f)
107
+ [left, bottom, right - left, top - bottom]
108
+ end
109
+
110
+ # The matrix taking the notebook page's own points onto the frame it was
111
+ # written in, which is given as fractions of the PDF page from its top left
112
+ # corner. On a Letter page, for example, the Scribe's 3:4 canvas is fitted
113
+ # to the width and runs past the top and bottom edges by 12 points each.
114
+ def placement(pdf_page, page)
115
+ left, bottom, width, height = box(pdf_page)
116
+ frame = page.frame
117
+ frame_width = frame.width * width
118
+ frame_height = frame.height * height
119
+ frame_left = left + (frame.left * width)
120
+ frame_bottom = bottom + height - (frame.top * height) - frame_height
121
+ [frame_width / page.width_in_points, 0, 0, frame_height / page.height_in_points, frame_left, frame_bottom]
122
+ end
123
+
124
+ # Takes the drawing calls PdfRenderer#draw_stroke makes and writes them as
125
+ # PDF content operators, to become an annotation's appearance stream.
126
+ # Points arrive in the notebook page's own space; +matrix+ maps them onto
127
+ # the PDF page.
128
+ class Appearance
129
+ # Control point offset for a quarter circle drawn as a Bezier curve.
130
+ KAPPA = 0.5523
131
+
132
+ def initialize(matrix)
133
+ @matrix = matrix
134
+ @operators = []
135
+ @points = []
136
+ @alpha = 1.0
137
+ @width = 0.0
138
+ @color = "000000"
139
+ end
140
+
141
+ def stroke_color(hex)
142
+ @color = hex
143
+ @operators << "#{rgb(hex).join(" ")} RG"
144
+ end
145
+
146
+ def fill_color(hex) = @operators << "#{rgb(hex).join(" ")} rg"
147
+
148
+ def line_width(width)
149
+ @width = [@width, width].max
150
+ @operators << "#{number(width)} w"
151
+ end
152
+
153
+ def move_to(point) = @operators << "#{coordinates(point)} m"
154
+
155
+ def line_to(point) = @operators << "#{coordinates(point)} l"
156
+
157
+ def stroke = @operators << "S"
158
+
159
+ # Four Bezier quarters, counter-clockwise from the rightmost point.
160
+ def fill_circle(center, radius)
161
+ x, y = center
162
+ @operators << "#{format_point([x + radius, y])} m"
163
+ quarters(center, radius).each { |curve| @operators << "#{curve.map { |p| format_point(p) }.join(" ")} c" }
164
+ @operators << "f"
165
+ @points << center
166
+ @corners = [[x - radius, y - radius], [x + radius, y + radius]]
167
+ end
168
+
169
+ def transparent(opacity, _stroke_opacity = opacity)
170
+ @alpha = opacity
171
+ yield
172
+ end
173
+
174
+ def color = rgb(@color)
175
+
176
+ # The widest line drawn, in PDF page units.
177
+ def width = @width * scale
178
+
179
+ # The stroke's points on the PDF page, as an ink list wants them.
180
+ def ink_list = @points.flat_map { |point| transform(point) }
181
+
182
+ # [left, bottom, right, top] on the PDF page, wide enough for the line.
183
+ def bounds
184
+ placed = (@points + @corners.to_a).map { |point| transform(point) }
185
+ pad = width / 2
186
+ xs = placed.map(&:first)
187
+ ys = placed.map(&:last)
188
+ [xs.min - pad, ys.min - pad, xs.max + pad, ys.max + pad]
189
+ end
190
+
191
+ # A form whose bounding box equals the annotation's rectangle, so the
192
+ # viewer draws it without scaling it.
193
+ def form(rect)
194
+ {
195
+ Type: :XObject,
196
+ Subtype: :Form,
197
+ BBox: rect,
198
+ Resources: resources,
199
+ raw_stream_content: content
200
+ }
201
+ end
202
+
203
+ private
204
+
205
+ def quarters(center, radius)
206
+ x, y = center
207
+ k = radius * KAPPA
208
+ [
209
+ [[x + radius, y + k], [x + k, y + radius], [x, y + radius]],
210
+ [[x - k, y + radius], [x - radius, y + k], [x - radius, y]],
211
+ [[x - radius, y - k], [x - k, y - radius], [x, y - radius]],
212
+ [[x + k, y - radius], [x + radius, y - k], [x + radius, y]]
213
+ ]
214
+ end
215
+
216
+ def content
217
+ header = ["q", "#{@matrix.map { |value| number(value) }.join(" ")} cm", "1 J", "1 j"]
218
+ header << "/Ink gs" if @alpha < 1.0
219
+ (header + @operators + ["Q"]).join("\n")
220
+ end
221
+
222
+ def resources
223
+ return {} unless @alpha < 1.0
224
+
225
+ { ExtGState: { Ink: { Type: :ExtGState, CA: @alpha, ca: @alpha } } }
226
+ end
227
+
228
+ def coordinates(point)
229
+ @points << point unless @points.last == point
230
+ format_point(point)
231
+ end
232
+
233
+ def format_point(point) = point.map { |value| number(value) }.join(" ")
234
+
235
+ def transform(point)
236
+ a, _, _, d, e, f = @matrix
237
+ [(a * point[0]) + e, (d * point[1]) + f]
238
+ end
239
+
240
+ def scale = @matrix.first
241
+
242
+ def rgb(hex) = hex.scan(/../).map { |pair| (pair.to_i(16) / 255.0).round(4) }
243
+
244
+ def number(value) = format("%.4f", value).sub(/\.?0+\z/, "")
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,243 @@
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
+ # A reader for the subset of binary Amazon Ion 1.0 that Kindle Scribe
7
+ # notebooks are written in.
8
+ #
9
+ # Notebook fragments share one symbol table, stored in its own database row,
10
+ # which imports the (textless) `YJ_symbols` catalogue. Symbols that resolve
11
+ # into an imported range have no text, so they are rendered as `$<sid>` --
12
+ # the same convention Amazon's own tooling uses, and the reason field names
13
+ # such as `$258` show up in the decoded structures.
14
+ module Ion
15
+ BVM = "\xE0\x01\x00\xEA".b
16
+
17
+ SYSTEM_SYMBOLS = [
18
+ nil, "$ion", "$ion_1_0", "$ion_symbol_table", "name", "version",
19
+ "imports", "symbols", "max_id", "$ion_shared_symbol_table"
20
+ ].freeze
21
+
22
+ class ParseError < Error; end
23
+
24
+ # A value carrying Ion annotations, e.g. `$490::{...}`.
25
+ Annotated = Struct.new(:annotations, :value) do
26
+ def type = annotations.first
27
+ end
28
+
29
+ # Resolves symbol IDs to text using the system table plus whatever a
30
+ # notebook's shared symbol table declares.
31
+ class SymbolTable
32
+ def initialize(imported_max_id: 0, locals: [])
33
+ @imported_max_id = imported_max_id
34
+ @locals = locals
35
+ end
36
+
37
+ def self.system = new
38
+
39
+ def local_base = SYSTEM_SYMBOLS.size + @imported_max_id
40
+
41
+ def [](sid)
42
+ return "$#{sid}" if sid.nil? || sid <= 0
43
+ return SYSTEM_SYMBOLS[sid] || "$#{sid}" if sid < SYSTEM_SYMBOLS.size
44
+
45
+ text = @locals[sid - local_base] if sid >= local_base
46
+ text || "$#{sid}"
47
+ end
48
+
49
+ # Builds a table from a `$ion_symbol_table`-annotated struct.
50
+ def self.from_fragment(bytes)
51
+ value = Reader.new(bytes, symbols: system).read_all.last
52
+ value = value.value if value.is_a?(Annotated)
53
+ raise ParseError, "symbol table is not a struct" unless value.is_a?(Hash)
54
+
55
+ imports = Array(value["imports"])
56
+ imported = imports.sum { |i| i.is_a?(Hash) ? i["max_id"].to_i : 0 }
57
+ new(imported_max_id: imported, locals: Array(value["symbols"]))
58
+ end
59
+ end
60
+
61
+ # Streams top-level Ion values out of a byte string.
62
+ class Reader
63
+ def initialize(bytes, symbols: SymbolTable.system)
64
+ @data = bytes.b
65
+ @pos = 0
66
+ @symbols = symbols
67
+ end
68
+
69
+ def read_all
70
+ values = []
71
+ skip_bvm
72
+ while @pos < @data.bytesize
73
+ value = read_value
74
+ values << value unless value.equal?(NOP)
75
+ skip_bvm
76
+ end
77
+ values
78
+ end
79
+
80
+ NOP = Object.new.freeze
81
+ private_constant :NOP
82
+
83
+ private
84
+
85
+ def skip_bvm
86
+ @pos += 4 while @data.byteslice(@pos, 4) == BVM
87
+ end
88
+
89
+ def byte
90
+ b = @data.getbyte(@pos) or raise ParseError, "unexpected end of Ion stream"
91
+ @pos += 1
92
+ b
93
+ end
94
+
95
+ def take(count)
96
+ raise ParseError, "unexpected end of Ion stream" if @pos + count > @data.bytesize
97
+
98
+ slice = @data.byteslice(@pos, count)
99
+ @pos += count
100
+ slice
101
+ end
102
+
103
+ def var_uint
104
+ value = 0
105
+ loop do
106
+ b = byte
107
+ value = (value << 7) | (b & 0x7F)
108
+ return value if b.anybits?(0x80)
109
+ end
110
+ end
111
+
112
+ def var_int
113
+ b = byte
114
+ negative = b.anybits?(0x40)
115
+ value = b & 0x3F
116
+ until b.anybits?(0x80)
117
+ b = byte
118
+ value = (value << 7) | (b & 0x7F)
119
+ end
120
+ negative ? -value : value
121
+ end
122
+
123
+ def bytes_to_uint(length)
124
+ take(length).each_byte.reduce(0) { |acc, b| (acc << 8) | b }
125
+ end
126
+
127
+ def read_value
128
+ descriptor = byte
129
+ type = descriptor >> 4
130
+ arg = descriptor & 0x0F
131
+ return nil if arg == 0x0F
132
+
133
+ length = case arg
134
+ when 0x0E then var_uint
135
+ else arg
136
+ end
137
+ dispatch(type, arg, length)
138
+ end
139
+
140
+ def dispatch(type, arg, length)
141
+ case type
142
+ when 0x0 then skip_pad(length)
143
+ when 0x1 then arg == 1
144
+ when 0x2 then bytes_to_uint(length)
145
+ when 0x3 then -bytes_to_uint(length)
146
+ when 0x4 then read_float(length)
147
+ when 0x5 then read_decimal(length)
148
+ when 0x6 then read_timestamp(length)
149
+ when 0x7 then @symbols[bytes_to_uint(length)]
150
+ when 0x8 then take(length).force_encoding(Encoding::UTF_8)
151
+ when 0x9, 0xA then take(length)
152
+ when 0xB, 0xC then read_sequence(length)
153
+ when 0xD then read_struct(arg, length)
154
+ when 0xE then read_annotated(length)
155
+ else raise ParseError, format("reserved Ion type 0x%X", type)
156
+ end
157
+ end
158
+
159
+ def skip_pad(length)
160
+ take(length)
161
+ NOP
162
+ end
163
+
164
+ def read_float(length)
165
+ case length
166
+ when 0 then 0.0
167
+ when 4 then take(4).unpack1("g")
168
+ when 8 then take(8).unpack1("G")
169
+ else raise ParseError, "invalid float width #{length}"
170
+ end
171
+ end
172
+
173
+ def read_decimal(length)
174
+ return 0r if length.zero?
175
+
176
+ stop = @pos + length
177
+ exponent = var_int
178
+ coefficient = read_signed_magnitude(stop - @pos)
179
+ coefficient * (10r**exponent)
180
+ end
181
+
182
+ def read_signed_magnitude(length)
183
+ return 0 if length.zero?
184
+
185
+ raw = take(length)
186
+ first = raw.getbyte(0)
187
+ negative = first.anybits?(0x80)
188
+ magnitude = raw.each_byte.with_index.reduce(0) do |acc, (b, i)|
189
+ (acc << 8) | (i.zero? ? b & 0x7F : b)
190
+ end
191
+ negative ? -magnitude : magnitude
192
+ end
193
+
194
+ # Timestamps are not used by stroke data; the raw bytes are preserved.
195
+ def read_timestamp(length) = take(length)
196
+
197
+ def read_sequence(length)
198
+ stop = @pos + length
199
+ values = []
200
+ while @pos < stop
201
+ value = read_value
202
+ values << value unless value.equal?(NOP)
203
+ end
204
+ values
205
+ end
206
+
207
+ def read_struct(arg, length)
208
+ length = var_uint if arg == 1
209
+ stop = @pos + length
210
+ fields = {}
211
+ while @pos < stop
212
+ name = @symbols[var_uint]
213
+ value = read_value
214
+ fields[name] = value unless value.equal?(NOP)
215
+ end
216
+ fields
217
+ end
218
+
219
+ def read_annotated(length)
220
+ stop = @pos + length
221
+ annot_length = var_uint
222
+ annot_stop = @pos + annot_length
223
+ annotations = []
224
+ annotations << @symbols[var_uint] while @pos < annot_stop
225
+ value = read_value
226
+ @pos = stop
227
+ Annotated.new(annotations.freeze, value)
228
+ end
229
+ end
230
+
231
+ module_function
232
+
233
+ # Strips any annotation wrappers from +value+.
234
+ def unwrap(value)
235
+ value = value.value while value.is_a?(Annotated)
236
+ value
237
+ end
238
+
239
+ def parse(bytes, symbols: SymbolTable.system)
240
+ Reader.new(bytes, symbols: symbols).read_all
241
+ end
242
+ end
243
+ end