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,232 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: 2026 Sergey Avseyev
3
+ # frozen_string_literal: true
4
+
5
+ require "prawn"
6
+
7
+ module KindleNotebook
8
+ # Draws decoded pages as vector graphics. Canvas units are sub-pixels at the
9
+ # page's normalised PPI (2520 on a Scribe); PDF user space is points, and its
10
+ # Y axis points the other way, so every coordinate is scaled and flipped.
11
+ class PdfRenderer
12
+ HIGHLIGHTER_ALPHA = 0.2
13
+ MIN_LINE_WIDTH = 0.15
14
+
15
+ # Pages go on US Letter unless asked otherwise, since the size the device
16
+ # records matches no standard sheet and print dialogs then offer to scale.
17
+ DEFAULT_PAPER = "letter"
18
+
19
+ # Asking for this size keeps each page exactly as the device recorded it.
20
+ NATIVE_PAPER = "native"
21
+
22
+ # Resolves a paper name to [width, height] in points, or nil for the size
23
+ # the notebook itself records. No name at all means the default.
24
+ def self.paper(name = nil)
25
+ name = DEFAULT_PAPER if name.nil?
26
+ return nil if name.to_s.casecmp(NATIVE_PAPER).zero?
27
+
28
+ PDF::Core::PageGeometry::SIZES[name.to_s.upcase] ||
29
+ raise(Error, "unknown paper size #{name}; use #{NATIVE_PAPER} or one of " \
30
+ "#{PDF::Core::PageGeometry::SIZES.keys.sort.join(", ")}")
31
+ end
32
+
33
+ # The pen reports a 12-bit pressure per point and the device narrows the
34
+ # line where the pen was lighter. The curve is not documented, so it was
35
+ # fitted against the thumbnails the device renders for the same pages; the
36
+ # fit says width follows how pressure varies *within* a stroke rather than
37
+ # its absolute level, since the stroke's overall width is already carried by
38
+ # nmdl.thickness. See "Stroke taper" in the README for the measurements.
39
+ PRESSURE_MAX = 4095
40
+ TAPER_SPAN = 1.15
41
+ MIN_WIDTH_FRACTION = 0.15
42
+
43
+ # Widths are rounded to this many steps so a stroke becomes a handful of
44
+ # sub-paths instead of one per sample point.
45
+ WIDTH_LEVELS = 8
46
+
47
+ # +paper+ is a resolved [width, height], or nil for the device's own size.
48
+ def initialize(render_template: true, paper: self.class.paper)
49
+ @render_template = render_template
50
+ @paper = paper
51
+ end
52
+
53
+ # +destination+ may be a path or any IO that responds to #write.
54
+ def render(pages, destination, title: nil)
55
+ raise Error, "notebook has no pages" if pages.empty?
56
+
57
+ document = new_document(pages.first, title)
58
+ pages.each_with_index do |page, index|
59
+ document.start_new_page(size: page_size(page), margin: 0) unless index.zero?
60
+ draw_page(document, page)
61
+ end
62
+ emit(document, destination)
63
+ end
64
+
65
+ # Draws one stroke in the page's own coordinates, points with the origin at
66
+ # the bottom left. +document+ is a Prawn document, or anything that answers
67
+ # the same few drawing calls.
68
+ def draw_stroke(document, page, stroke)
69
+ scale = POINTS_PER_INCH / page.ppi
70
+ nominal = [stroke.thickness * scale, MIN_LINE_WIDTH].max
71
+ points = stroke_points(page, stroke, scale)
72
+
73
+ document.stroke_color(rgb_hex(stroke.color))
74
+ document.fill_color(rgb_hex(stroke.color))
75
+
76
+ with_alpha(document, stroke_alpha(stroke)) do
77
+ if stroke.dot?
78
+ document.line_width(nominal)
79
+ document.fill_circle(points.first, nominal / 2)
80
+ else
81
+ draw_runs(document, points, segment_widths(stroke, nominal))
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def new_document(first_page, title)
89
+ Prawn::Document.new(
90
+ page_size: page_size(first_page),
91
+ margin: 0,
92
+ info: {
93
+ Title: title,
94
+ Creator: "#{PROGRAM} #{VERSION}",
95
+ Producer: "#{PROGRAM} #{VERSION}"
96
+ }.compact
97
+ )
98
+ end
99
+
100
+ def emit(document, destination)
101
+ if destination.respond_to?(:write)
102
+ destination.write(document.render)
103
+ else
104
+ document.render_file(destination)
105
+ end
106
+ destination
107
+ end
108
+
109
+ def page_size(page) = @paper || [page.width_in_points, page.height_in_points]
110
+
111
+ def draw_page(document, page)
112
+ document.cap_style(:round)
113
+ document.join_style(:round)
114
+ fitted(document, page) do
115
+ draw_template(document, page) if @render_template
116
+ page.strokes.each { |stroke| draw_stroke(document, page, stroke) }
117
+ end
118
+ end
119
+
120
+ # On a chosen paper size the canvas is scaled to fit and centred, keeping
121
+ # its proportions, so nothing is stretched or cropped.
122
+ def fitted(document, page, &)
123
+ return yield if @paper.nil?
124
+
125
+ width = page.width_in_points
126
+ height = page.height_in_points
127
+ scale = [@paper.first / width, @paper.last / height].min
128
+ document.transformation_matrix(scale, 0, 0, scale,
129
+ (@paper.first - (width * scale)) / 2,
130
+ (@paper.last - (height * scale)) / 2, &)
131
+ end
132
+
133
+ def draw_template(document, page)
134
+ template = page.template
135
+ return if template.nil? || template.empty?
136
+ return if template.width <= 0 || template.height <= 0
137
+
138
+ # A template draws itself in its own space, which is stretched onto the page.
139
+ scale = [page.width_in_points / template.width, page.height_in_points / template.height]
140
+ template.shapes.each { |shape| draw_shape(document, page, shape, scale) }
141
+ end
142
+
143
+ def draw_shape(document, page, shape, scale)
144
+ alpha = alpha_of(shape.color)
145
+ return if alpha.zero?
146
+
147
+ scale_x, scale_y = scale
148
+ height = page.height_in_points
149
+ document.stroke_color(rgb_hex(shape.color))
150
+ document.line_width([shape.width * scale_x, MIN_LINE_WIDTH].max)
151
+ with_alpha(document, alpha) do
152
+ shape.segments.each do |segment|
153
+ polyline(document, segment.map { |x, y| [x * scale_x, height - (y * scale_y)] })
154
+ end
155
+ end
156
+ end
157
+
158
+ # One sub-path per run of equal width. Runs share their junction point, so
159
+ # with round caps and joins the seam between them is invisible.
160
+ def draw_runs(document, points, widths)
161
+ index = 0
162
+ while index < widths.size
163
+ width = widths[index]
164
+ last = index
165
+ last += 1 while last + 1 < widths.size && widths[last + 1] == width
166
+
167
+ document.line_width(width)
168
+ polyline(document, points[index..(last + 1)])
169
+ index = last + 1
170
+ end
171
+ end
172
+
173
+ # One width per segment, taken from the pressure at the segment's start and
174
+ # measured against the average pressure of the stroke it belongs to.
175
+ def segment_widths(stroke, nominal)
176
+ count = stroke.xs.size - 1
177
+ levels = taper_levels(stroke, count)
178
+ return Array.new(count, nominal) unless levels
179
+
180
+ mean = levels.sum / levels.size
181
+ levels.map { |level| width_for(level - mean, nominal) }
182
+ end
183
+
184
+ # The square root maps the raw reading onto something nearer to how wide the
185
+ # nib sits at that pressure.
186
+ #
187
+ # Only the pen tapers. The highlighter records pressure too, but it barely
188
+ # varies along a stroke while differing a lot between strokes, so following
189
+ # it would give each highlighter band an arbitrary thickness rather than the
190
+ # fixed one the device draws.
191
+ def taper_levels(stroke, count)
192
+ pressures = stroke.pressures
193
+ return nil if stroke.highlighter?
194
+ return nil unless pressures && pressures.size == stroke.xs.size
195
+
196
+ pressures.first(count).map { |p| Math.sqrt(p.clamp(0, PRESSURE_MAX) / PRESSURE_MAX.to_f) }
197
+ end
198
+
199
+ def width_for(offset, nominal)
200
+ factor = 1.0 + (TAPER_SPAN * offset)
201
+ factor = (factor * WIDTH_LEVELS).round / WIDTH_LEVELS.to_f
202
+ [nominal * factor, nominal * MIN_WIDTH_FRACTION, MIN_LINE_WIDTH].max
203
+ end
204
+
205
+ def stroke_points(page, stroke, scale)
206
+ height = page.height_in_points
207
+ stroke.xs.each_with_index.map { |x, index| [x * scale, height - (stroke.ys[index] * scale)] }
208
+ end
209
+
210
+ def stroke_alpha(stroke) = stroke.highlighter? ? HIGHLIGHTER_ALPHA : alpha_of(stroke.color)
211
+
212
+ def polyline(document, points)
213
+ document.move_to(points.first)
214
+ points.drop(1).each { |point| document.line_to(point) }
215
+ document.stroke
216
+ end
217
+
218
+ def with_alpha(document, alpha, &)
219
+ alpha < 1.0 ? document.transparent(alpha, &) : yield
220
+ end
221
+
222
+ # A colour is a packed integer; the alpha byte is only meaningful when the
223
+ # value actually carries one (templates do, pen colours do not).
224
+ def alpha_of(color)
225
+ return 1.0 if color <= 0xFF_FFFF
226
+
227
+ ((color >> 24) & 0xFF) / 255.0
228
+ end
229
+
230
+ def rgb_hex(color) = format("%06X", color & 0xFF_FFFF)
231
+ end
232
+ end
@@ -0,0 +1,90 @@
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
+ # Every per-point array inside a stroke (x, y, pressure, tilt, ...) is stored
7
+ # as a second-order delta stream: the firmware records the change *of the
8
+ # change* between successive samples, which stays close to zero for a smooth
9
+ # pen movement and therefore packs into very few bits.
10
+ #
11
+ # [0x01 0x01] magic
12
+ # [uint32-LE] number of encoded values
13
+ # [nibble stream] one 4-bit instruction per value, two per byte
14
+ # [operand bytes] variable-width operands referenced by the nibbles
15
+ #
16
+ # Instruction nibble layout:
17
+ #
18
+ # bits 0-1 (n) operand width: 0 = none, 1..3 = that many little-endian bytes
19
+ # bit 2 the increment is +n itself; no operand bytes are consumed
20
+ # bit 3 negate the increment
21
+ module StrokeDecoder
22
+ MAGIC = "\x01\x01".b
23
+ HEADER_SIZE = 6
24
+
25
+ class DecodeError < Error; end
26
+
27
+ module_function
28
+
29
+ def decode(data, count)
30
+ return [] if count <= 0
31
+
32
+ data = data.to_s.b
33
+ raise DecodeError, "expected #{count} values in a #{data.bytesize}-byte blob" if data.bytesize < HEADER_SIZE
34
+ unless data.byteslice(0, 2) == MAGIC
35
+ raise DecodeError, "bad stroke signature #{data.byteslice(0, 2).unpack1("H*")}"
36
+ end
37
+
38
+ instructions, operand_offset = read_instructions(data, count)
39
+ accumulate(data, instructions, operand_offset, count)
40
+ end
41
+
42
+ def read_instructions(data, count)
43
+ nibbles = []
44
+ offset = HEADER_SIZE
45
+ while nibbles.size < count && offset < data.bytesize
46
+ byte = data.getbyte(offset)
47
+ offset += 1
48
+ nibbles << (byte >> 4) << (byte & 0x0F)
49
+ end
50
+ nibbles.pop if nibbles.size > count
51
+ [nibbles, offset]
52
+ end
53
+
54
+ def accumulate(data, instructions, offset, count)
55
+ values = Array.new(count)
56
+ change = 0
57
+ value = 0
58
+
59
+ count.times do |index|
60
+ instruction = instructions[index] || 0
61
+ width = instruction & 0x03
62
+ increment, offset =
63
+ if instruction.anybits?(0x04)
64
+ [width, offset]
65
+ else
66
+ read_operand(data, offset, width)
67
+ end
68
+ increment = -increment if instruction.anybits?(0x08)
69
+
70
+ if index.zero?
71
+ change = 0
72
+ value = increment
73
+ else
74
+ change += increment
75
+ value += change
76
+ end
77
+ values[index] = value
78
+ end
79
+ values
80
+ end
81
+
82
+ def read_operand(data, offset, width)
83
+ return [0, offset] if width.zero?
84
+ raise DecodeError, "stroke stream truncated" if offset + width > data.bytesize
85
+
86
+ value = data.byteslice(offset, width).each_byte.with_index.sum { |b, i| b << (8 * i) }
87
+ [value, offset + width]
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,90 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: 2026 Sergey Avseyev
3
+ # frozen_string_literal: true
4
+
5
+ require "json"
6
+
7
+ module KindleNotebook
8
+ # The names you gave your notebooks on the device.
9
+ #
10
+ # A Kindle keeps them on its system partition, which it does not share over
11
+ # MTP, and they are not in the notebook files either, so they have to be
12
+ # supplied once by hand. Notebooks that still have no name can be appended to
13
+ # the file for filling in, which is what `sync` does.
14
+ class Titles
15
+ HEADER = "# Names for your Kindle notebooks, which the device does not share over MTP.\n" \
16
+ "# Fill in a name between the quotes and it will be used from then on."
17
+ private_constant :HEADER
18
+
19
+ attr_reader :path
20
+
21
+ def self.load(path)
22
+ return new if path.nil?
23
+ return new(path: path) unless File.file?(path)
24
+
25
+ new(path: path, names: parse(File.read(path), path))
26
+ end
27
+
28
+ def self.parse(text, path)
29
+ data = json?(path) ? JSON.parse(text) : yaml(text)
30
+ raise Error, "#{path}: a title map must be a mapping of uuid to name" unless data.is_a?(Hash)
31
+
32
+ data.transform_keys(&:to_s).transform_values(&:to_s)
33
+ end
34
+
35
+ def self.yaml(text)
36
+ require "yaml"
37
+ YAML.safe_load(text, permitted_classes: [], aliases: false) || {}
38
+ end
39
+
40
+ def self.json?(path) = File.extname(path).casecmp(".json").zero?
41
+
42
+ def initialize(path: nil, names: {})
43
+ @path = path
44
+ @names = names
45
+ @missing = {}
46
+ end
47
+
48
+ def [](key) = @names[key]
49
+
50
+ # True once a uuid appears in the file at all, even without a name yet, so
51
+ # it is not offered for filling in twice.
52
+ def known?(uuid) = @names.key?(uuid)
53
+
54
+ # Remembers a notebook that has no name, with a note to recognise it by.
55
+ def note_missing(uuid, note)
56
+ @missing[uuid] ||= note unless known?(uuid)
57
+ end
58
+
59
+ def missing = @missing.size
60
+
61
+ # Adds the remembered notebooks to the file, and returns how many.
62
+ def record_missing
63
+ return 0 if @path.nil? || @missing.empty?
64
+
65
+ self.class.json?(@path) ? write_json : append_yaml
66
+ count = @missing.size
67
+ @missing.each_key { |uuid| @names[uuid] = "" }
68
+ @missing.clear
69
+ count
70
+ end
71
+
72
+ private
73
+
74
+ def append_yaml
75
+ File.open(@path, "a") do |file|
76
+ file.puts(HEADER) unless File.size?(@path)
77
+ @missing.each do |uuid, note|
78
+ file.puts
79
+ file.puts("# #{note}")
80
+ file.puts(%(#{uuid}: ""))
81
+ end
82
+ end
83
+ end
84
+
85
+ def write_json
86
+ merged = @names.merge(@missing.transform_values { "" })
87
+ File.write(@path, "#{JSON.pretty_generate(merged)}\n")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,10 @@
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
+ VERSION = "1.0.0"
7
+
8
+ # The name this library is published and invoked under.
9
+ PROGRAM = "nbk2pdf"
10
+ end
data/lib/nbk2pdf.rb ADDED
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-FileCopyrightText: 2026 Sergey Avseyev
3
+ # frozen_string_literal: true
4
+
5
+ # Reads Kindle Scribe notebooks (`nbk` files) and renders them as vector PDFs.
6
+ module KindleNotebook
7
+ class Error < StandardError; end
8
+ end
9
+
10
+ require "kindle_notebook/version"
11
+ require "kindle_notebook/container"
12
+ require "kindle_notebook/ion"
13
+ require "kindle_notebook/stroke_decoder"
14
+ require "kindle_notebook/krds"
15
+ require "kindle_notebook/notebook"
16
+ require "kindle_notebook/document"
17
+ require "kindle_notebook/pdf_renderer"
18
+ require "kindle_notebook/document_renderer"
19
+ require "kindle_notebook/titles"
20
+ require "kindle_notebook/device"
21
+ require "kindle_notebook/converter"
22
+ require "kindle_notebook/cli"
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nbk2pdf
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Sergey Avseyev
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: combine_pdf
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: prawn
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sqlite3
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ description: |
55
+ Reads the `nbk` files a Kindle Scribe writes and renders them as vector PDFs,
56
+ complete with the ruled or squared background the notes were written on,
57
+ and adds what was written on a PDF on the device to a copy of that PDF.
58
+
59
+ Includes a reader for the signature container the device wraps its SQLite
60
+ database in, a binary Amazon Ion parser, and a decoder for the second-order
61
+ delta encoding the firmware stores pen strokes in. Ships the `nbk2pdf`
62
+ command line tool.
63
+ email:
64
+ - sergey.avseyev@gmail.com
65
+ executables:
66
+ - nbk2pdf
67
+ extensions: []
68
+ extra_rdoc_files: []
69
+ files:
70
+ - CHANGELOG.md
71
+ - COPYING
72
+ - README.md
73
+ - exe/nbk2pdf
74
+ - lib/kindle_notebook/cli.rb
75
+ - lib/kindle_notebook/container.rb
76
+ - lib/kindle_notebook/converter.rb
77
+ - lib/kindle_notebook/device.rb
78
+ - lib/kindle_notebook/document.rb
79
+ - lib/kindle_notebook/document_renderer.rb
80
+ - lib/kindle_notebook/ion.rb
81
+ - lib/kindle_notebook/krds.rb
82
+ - lib/kindle_notebook/notebook.rb
83
+ - lib/kindle_notebook/pdf_renderer.rb
84
+ - lib/kindle_notebook/stroke_decoder.rb
85
+ - lib/kindle_notebook/titles.rb
86
+ - lib/kindle_notebook/version.rb
87
+ - lib/nbk2pdf.rb
88
+ licenses:
89
+ - GPL-3.0-or-later
90
+ metadata:
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 3.1.0
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 4.0.22
107
+ specification_version: 4
108
+ summary: Convert Kindle Scribe notebooks into vector PDFs
109
+ test_files: []