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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/COPYING +674 -0
- data/README.md +490 -0
- data/exe/nbk2pdf +8 -0
- data/lib/kindle_notebook/cli.rb +274 -0
- data/lib/kindle_notebook/container.rb +82 -0
- data/lib/kindle_notebook/converter.rb +112 -0
- data/lib/kindle_notebook/device.rb +145 -0
- data/lib/kindle_notebook/document.rb +133 -0
- data/lib/kindle_notebook/document_renderer.rb +247 -0
- data/lib/kindle_notebook/ion.rb +243 -0
- data/lib/kindle_notebook/krds.rb +109 -0
- data/lib/kindle_notebook/notebook.rb +442 -0
- data/lib/kindle_notebook/pdf_renderer.rb +232 -0
- data/lib/kindle_notebook/stroke_decoder.rb +90 -0
- data/lib/kindle_notebook/titles.rb +90 -0
- data/lib/kindle_notebook/version.rb +10 -0
- data/lib/nbk2pdf.rb +22 -0
- metadata +109 -0
data/README.md
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
# nbk2pdf
|
|
2
|
+
|
|
3
|
+
Converts Kindle Scribe notebooks (`nbk` files) into vector PDFs, including the
|
|
4
|
+
ruled or squared background the notes were written on.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
nbk2pdf sync ~/Documents/scribe # straight off a connected Kindle
|
|
8
|
+
nbk2pdf ~/kindle/.notebooks # or from files you already have
|
|
9
|
+
nbk2pdf -o ~/Documents/scribe --titles titles.yml /media/kindle/.notebooks
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
It also takes a PDF you wrote on with the pen and produces a copy of it with
|
|
13
|
+
the handwriting on the pages, ready to print. See [Writing on a
|
|
14
|
+
PDF](#writing-on-a-pdf).
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
gem install nbk2pdf
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or add it to a Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "nbk2pdf"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Ruby 3.1 or newer. The `sqlite3` gem ships a prebuilt native
|
|
29
|
+
extension for common platforms; elsewhere it builds against the system SQLite.
|
|
30
|
+
|
|
31
|
+
`nbk2pdf sync` additionally needs `gio`, from gvfs, which is already present on
|
|
32
|
+
most Linux desktops. Everything else works without it.
|
|
33
|
+
|
|
34
|
+
## Syncing from a connected Kindle
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
nbk2pdf sync # into the current directory
|
|
38
|
+
nbk2pdf sync ~/notes # into a directory of your choosing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A Kindle speaks MTP rather than presenting a disk, so it cannot be mounted with
|
|
42
|
+
`mount(8)`. On Linux, gvfs exposes MTP devices as FUSE paths once `gio mount`
|
|
43
|
+
has attached them, and that is what `sync` drives:
|
|
44
|
+
|
|
45
|
+
1. If a Kindle is already mounted, its notebooks are used as they are.
|
|
46
|
+
2. Otherwise the MTP volume is located with `gio mount -li` and mounted.
|
|
47
|
+
3. Every notebook under `.notebooks/` on the device is converted into the
|
|
48
|
+
destination directory, which is created if it does not exist.
|
|
49
|
+
|
|
50
|
+
Notebooks and PDFs written on are kept apart. Notebooks go side by side into
|
|
51
|
+
`notebooks/`. Each PDF goes into `documents/`, under the same folders it sits in
|
|
52
|
+
under `documents/` on the Kindle:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
~/notes/
|
|
56
|
+
├── notebooks/
|
|
57
|
+
│ ├── Reading list.pdf
|
|
58
|
+
│ └── 2026-09-22_dee63122-45e3-c7e2-ba57-7824a0bd244b.pdf
|
|
59
|
+
└── documents/
|
|
60
|
+
└── HoTT/
|
|
61
|
+
└── workbook.pdf
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Only `documents/` is looked in for PDFs, since it is the only folder the
|
|
65
|
+
Kindle's own reader lists, and so the only place a PDF can have been written on
|
|
66
|
+
with the pen.
|
|
67
|
+
|
|
68
|
+
The device is left mounted afterwards. Only top-level notebook folders are
|
|
69
|
+
converted, so the clipboard scratch buffer is skipped.
|
|
70
|
+
|
|
71
|
+
If nothing is found, USB is checked directly for Amazon's vendor id (`1949`) so
|
|
72
|
+
the two cases can be told apart:
|
|
73
|
+
|
|
74
|
+
* **not on the bus at all** -- reported immediately, since waiting will not
|
|
75
|
+
help: connect the cable and try again.
|
|
76
|
+
* **plugged in but not offered by gvfs** -- usually the screen is locked, or the
|
|
77
|
+
device is still settling after being attached, so this one is waited on for a
|
|
78
|
+
few seconds before giving up with a suggestion to unlock the screen or
|
|
79
|
+
reconnect the cable.
|
|
80
|
+
|
|
81
|
+
A Kindle that has dropped off the bus does not come back by itself; it needs the
|
|
82
|
+
cable unplugged and reconnected, which is what the message says.
|
|
83
|
+
|
|
84
|
+
The waits are measured against a monotonic clock rather than by counting sleeps,
|
|
85
|
+
because each probe of gvfs costs about half a second and a naive count made a
|
|
86
|
+
ten second budget take thirty.
|
|
87
|
+
|
|
88
|
+
### Why notebook names have to be supplied
|
|
89
|
+
|
|
90
|
+
The Kindle shows a name for each notebook, but it is not in anything the device
|
|
91
|
+
shares over MTP. Checked on a Scribe:
|
|
92
|
+
|
|
93
|
+
* the `nbk` file does not contain it in UTF-8 or either UTF-16 byte order, and
|
|
94
|
+
its only metadata category holds just `book_id` and `language`;
|
|
95
|
+
* `book_id` decodes to a random version-4 UUID, so it is an opaque handle with
|
|
96
|
+
nothing to derive a name from;
|
|
97
|
+
* `.sync/KSDKNoteSyncDB.sqlite` holds notebook ids and sync bookkeeping only,
|
|
98
|
+
and does not mention the `book_id` either;
|
|
99
|
+
* a sweep of 1115 files and 84 MB across every other directory MTP exposes
|
|
100
|
+
turned up nothing, in any of the three encodings.
|
|
101
|
+
|
|
102
|
+
The names live on the system partition, which MTP does not share -- it offers
|
|
103
|
+
only `Internal Storage`, that is `/mnt/us`. So `sync` names files by date and
|
|
104
|
+
uuid, and offers the unnamed ones to `--titles` to fill in once.
|
|
105
|
+
|
|
106
|
+
## Writing on a PDF
|
|
107
|
+
|
|
108
|
+
Copy a PDF into `documents/` on the Kindle, open it there, and write on its
|
|
109
|
+
pages. Then:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
nbk2pdf sync ~/Documents/scribe # with everything else
|
|
113
|
+
nbk2pdf -o done.pdf "$KINDLE/documents/HoTT/workbook.pdf" # just the one
|
|
114
|
+
nbk2pdf --annotations -o done.pdf "$KINDLE/documents/HoTT/workbook.pdf"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The result is the original PDF, with its text, links and named destinations
|
|
118
|
+
unchanged and the ink drawn on each page it was written on. By default the ink
|
|
119
|
+
becomes part of the page, so every viewer and printer shows it and the pressure
|
|
120
|
+
taper is kept.
|
|
121
|
+
|
|
122
|
+
The copy is rebuilt rather than edited in place, and some document-level
|
|
123
|
+
entries do not survive it. Seen on the sample: the catalog's `/OpenAction` and
|
|
124
|
+
`/PageMode`, and the original dates and producer in the document info. From how
|
|
125
|
+
the PDF library works, `/PageLabels`, XMP metadata, the structure tree used for
|
|
126
|
+
accessibility, and optional-content layers (all layers end up shown) would be
|
|
127
|
+
lost too. Page content, links and named destinations are kept, and the library
|
|
128
|
+
carries the outline across as well.
|
|
129
|
+
|
|
130
|
+
`--annotations` adds each stroke as a PDF ink annotation instead, which a viewer
|
|
131
|
+
can hide, move or delete. Each carries an appearance drawn by the same code, so
|
|
132
|
+
it keeps the taper too, and it is flagged to print. Whether it actually prints
|
|
133
|
+
is up to the viewer: most print dialogs have a setting that leaves annotations
|
|
134
|
+
out. For paper, prefer the default.
|
|
135
|
+
|
|
136
|
+
A single PDF is written into the current directory unless `-o` says otherwise,
|
|
137
|
+
and the tool refuses to write over the original. `sync` writes it into
|
|
138
|
+
`DIR/documents/`, in the folders it has on the Kindle, under its own name.
|
|
139
|
+
|
|
140
|
+
The Kindle never changes the PDF itself, which is why copying it back over USB
|
|
141
|
+
gives you the blank one. What it writes instead is described in [6.
|
|
142
|
+
Documents](#6-documents-documentrb-krdsrb). Ink that cannot be placed on a page
|
|
143
|
+
is reported on stderr rather than dropped silently.
|
|
144
|
+
|
|
145
|
+
## Naming
|
|
146
|
+
|
|
147
|
+
Standalone Scribe notebooks do not store the name you gave them on the device --
|
|
148
|
+
that lives in the Kindle's own library database, not in the `nbk` file. The
|
|
149
|
+
output name is therefore resolved in this order:
|
|
150
|
+
|
|
151
|
+
1. an entry in `--titles`, keyed by the notebook's uuid or its book id
|
|
152
|
+
2. the title inside the notebook, when it has one (notebooks attached to a book do)
|
|
153
|
+
3. the notebook's modification date followed by its uuid,
|
|
154
|
+
e.g. `2025-09-19_29b6da80-211a-ebf9-56a2-ade81624ebfd.pdf`
|
|
155
|
+
|
|
156
|
+
`--titles` takes a JSON or YAML mapping:
|
|
157
|
+
|
|
158
|
+
```yaml
|
|
159
|
+
29b6da80-211a-ebf9-56a2-ade81624ebfd: The Humongous Book of Calculus Problems
|
|
160
|
+
bfa1ee49-16e8-a25e-0852-7273f4ef9e43: Reading list
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
You do not have to write it by hand. Point `--titles` at a file that does not
|
|
164
|
+
exist yet and every notebook without a name is added to it, with its page count
|
|
165
|
+
and date as a comment so you can tell them apart:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
$ nbk2pdf sync --titles titles.yml notes
|
|
169
|
+
...
|
|
170
|
+
3 notebooks had no name; added to titles.yml to fill in
|
|
171
|
+
|
|
172
|
+
$ cat titles.yml
|
|
173
|
+
# 20 pages, last written 2026-09-19
|
|
174
|
+
29b6da80-211a-ebf9-56a2-ade81624ebfd: ""
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Fill a name in between the quotes and every later run uses it. An entry left
|
|
178
|
+
blank still counts as answered, so you are not asked about it again.
|
|
179
|
+
|
|
180
|
+
## Usage
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
Convert Kindle Scribe notebooks to PDF.
|
|
184
|
+
|
|
185
|
+
Usage: nbk2pdf [options] PATH...
|
|
186
|
+
nbk2pdf sync [options] [DIR]
|
|
187
|
+
|
|
188
|
+
PATH may be an `nbk` file, a notebook directory, or any directory
|
|
189
|
+
containing notebooks (they are found recursively). It may also be a
|
|
190
|
+
PDF on the Kindle, to get a copy with what was written on it.
|
|
191
|
+
|
|
192
|
+
sync converts every notebook on a Kindle attached over USB into
|
|
193
|
+
DIR/notebooks, and every PDF on it that has been written on into
|
|
194
|
+
DIR/documents, in the folders it has under documents/ on the
|
|
195
|
+
Kindle. DIR is the current directory when left out. The device is
|
|
196
|
+
mounted over MTP first if it is not mounted already, and is left
|
|
197
|
+
mounted afterwards.
|
|
198
|
+
|
|
199
|
+
Options:
|
|
200
|
+
-o, --output PATH Output file, or directory for several notebooks
|
|
201
|
+
-n, --name NAME Override the output file name (single notebook only)
|
|
202
|
+
-t, --titles FILE JSON/YAML map of notebook uuid to the name you gave it
|
|
203
|
+
--[no-]template Draw the notebook's ruled background (default: yes)
|
|
204
|
+
-p, --paper SIZE Page size: letter (default), a4, native, ...
|
|
205
|
+
-a, --annotations Add ink to a PDF as annotations, not drawn into the page
|
|
206
|
+
-q, --quiet Only report errors
|
|
207
|
+
-h, --help Show this message
|
|
208
|
+
-v, --version Show the version
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A Scribe's `.notebooks` folder also holds a `clipboard/` scratch buffer, which
|
|
212
|
+
recursive discovery picks up like any other notebook. Pass notebook directories
|
|
213
|
+
explicitly if you would rather skip it.
|
|
214
|
+
|
|
215
|
+
## The file format
|
|
216
|
+
|
|
217
|
+
Nothing about `nbk` is documented by Amazon, so here is what the converter does.
|
|
218
|
+
Each layer has its own module under `lib/kindle_notebook/`.
|
|
219
|
+
|
|
220
|
+
### 1. The signature container (`container.rb`)
|
|
221
|
+
|
|
222
|
+
An `nbk` file is a SQLite database interleaved with 1024-byte signature blocks:
|
|
223
|
+
one after the first kibibyte, then one after every mebibyte.
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
[ 1 KiB db ][ 1 KiB sig ][ 1 MiB db ][ 1 KiB sig ][ 1 MiB db ] ...
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Strip those and SQLite opens the result cleanly -- `PRAGMA integrity_check`
|
|
230
|
+
passes on every sample. The expected number of blocks is cross-checked against
|
|
231
|
+
the page size and page count in the SQLite header, so a file that does not fit
|
|
232
|
+
the layout is rejected rather than silently mangled.
|
|
233
|
+
|
|
234
|
+
### 2. Ion fragments (`ion.rb`)
|
|
235
|
+
|
|
236
|
+
The database keeps content as rows of binary [Amazon
|
|
237
|
+
Ion](https://amazon-ion.github.io/ion-docs/) in `fragments` and
|
|
238
|
+
`local_delta_fragments` (the latter overrides the former, and marks deletions).
|
|
239
|
+
One row, `$ion_symbol_table`, holds the shared symbol table: it imports a
|
|
240
|
+
catalogue named `YJ_symbols` (max id 816) and appends the notebook's own
|
|
241
|
+
`nmdl.*` symbols.
|
|
242
|
+
|
|
243
|
+
`YJ_symbols` ships no text, so symbols inside the imported range keep their
|
|
244
|
+
numeric spelling -- which is why field names such as `$258` and `$141` appear in
|
|
245
|
+
the decoded structures, exactly as Amazon's own tooling renders them.
|
|
246
|
+
|
|
247
|
+
Fragment types are the Ion annotation on each row's top-level value:
|
|
248
|
+
|
|
249
|
+
| Type | Meaning |
|
|
250
|
+
|--------|-----------------------------------------------------------|
|
|
251
|
+
| `$258` | reading order: `$169` sections, the `$351` one lists pages |
|
|
252
|
+
| `$260` | a page, or a template |
|
|
253
|
+
| `$259` | a container of child items (`$146`), reached through `$176`|
|
|
254
|
+
| `$490` | book metadata (`book_id`, `language`, sometimes `title`) |
|
|
255
|
+
| `$538` | document data, including the default template |
|
|
256
|
+
|
|
257
|
+
### 3. Pages and strokes (`notebook.rb`, `stroke_decoder.rb`)
|
|
258
|
+
|
|
259
|
+
A page records its canvas (15624 x 20832 sub-pixels at 2520 ppi on a Scribe) and
|
|
260
|
+
a tree of content items. Walking it through `$176` references and `$146`
|
|
261
|
+
children reaches the leaves, each carrying `nmdl.stroke_points`.
|
|
262
|
+
|
|
263
|
+
Coordinates are stored as a second-order delta stream -- the change *of the
|
|
264
|
+
change* between samples, which stays near zero for a smooth pen movement:
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
[0x01 0x01] magic
|
|
268
|
+
[uint32-LE] number of values
|
|
269
|
+
[nibble stream] one 4-bit instruction per value, two per byte
|
|
270
|
+
[operand bytes] variable-width operands
|
|
271
|
+
|
|
272
|
+
bits 0-1 operand width: 0 = none, 1..3 = that many little-endian bytes
|
|
273
|
+
bit 2 the increment is the width value itself; no operands consumed
|
|
274
|
+
bit 3 negate the increment
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Decoded values are relative to the stroke's `nmdl.stroke_bounds` origin.
|
|
278
|
+
|
|
279
|
+
Strokes also carry pressure, tilt and per-point thickness factors, decoded by
|
|
280
|
+
the same scheme. Pressure drives the stroke taper (below). Tilt turns out to be
|
|
281
|
+
recorded once per stroke rather than per point, and both adjust factors are a
|
|
282
|
+
flat `100` everywhere in the samples, so neither is used.
|
|
283
|
+
|
|
284
|
+
### 4. Templates
|
|
285
|
+
|
|
286
|
+
Each page names the background it was written on through `nmdl.template_id`.
|
|
287
|
+
It can be:
|
|
288
|
+
|
|
289
|
+
* a reference to a template fragment, e.g. `narrow_rule_margin_1860x2480`
|
|
290
|
+
* a placeholder symbol (`$349`), meaning the page is blank
|
|
291
|
+
* absent, in which case the document's default from `$538` applies
|
|
292
|
+
|
|
293
|
+
A template draws itself in its own 1860 x 2480 space with a flat op list --
|
|
294
|
+
`0 x y` to move, `1 x y` to draw, `4` to close -- plus an ARGB colour and a line
|
|
295
|
+
width. The converter scales that space onto the page.
|
|
296
|
+
|
|
297
|
+
### 5. Rendering (`pdf_renderer.rb`)
|
|
298
|
+
|
|
299
|
+
Every PDF page matches the Scribe canvas exactly (`canvas / ppi * 72` points, so
|
|
300
|
+
446.4 x 595.2 pt), with the Y axis flipped. Strokes become round-capped polyline
|
|
301
|
+
paths; a one-point stroke becomes a filled dot; highlighter strokes (brush type
|
|
302
|
+
1) are drawn at 20% opacity.
|
|
303
|
+
|
|
304
|
+
#### Paper size
|
|
305
|
+
|
|
306
|
+
The device records its pages at 446.4 x 595.2 pt, which is 6.20 x 8.27 in or
|
|
307
|
+
157.5 x 210 mm, in a 3:4 ratio. That is the Scribe's screen, and it matches no
|
|
308
|
+
standard paper size, so a print dialog offers to scale it.
|
|
309
|
+
|
|
310
|
+
Pages therefore go on **US Letter** by default, scaled to fit and centred so
|
|
311
|
+
nothing is stretched or cropped. `--paper` chooses something else:
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
nbk2pdf --paper a4 notebook/ # A4 instead
|
|
315
|
+
nbk2pdf --paper native notebook/ # exactly as the device recorded it
|
|
316
|
+
nbk2pdf sync --paper a4 ~/notes
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Every size Prawn knows is accepted, including `a3`..`a6`, `letter`, `legal`,
|
|
320
|
+
`tabloid` and `executive`. On Letter the canvas fits the height and leaves 9 pt
|
|
321
|
+
at each side; on A4 it fits the width and leaves 24 pt above and below.
|
|
322
|
+
|
|
323
|
+
Note that the PDF sets no `/ViewerPreferences` and no `/PrintScaling`, so it
|
|
324
|
+
never tells a viewer whether scaling is allowed. If a print dialog greys out its
|
|
325
|
+
scale field, that is the viewer's own setting -- in Evince, the manual scale is
|
|
326
|
+
only editable once *Page Scaling* is set to *None*.
|
|
327
|
+
|
|
328
|
+
#### Stroke taper
|
|
329
|
+
|
|
330
|
+
The pen records a 12-bit pressure per point, and it varies a lot inside a single
|
|
331
|
+
stroke -- a median spread of 3071 out of 4095 across the samples -- so drawing
|
|
332
|
+
every stroke at one width loses the thinning at the ends of each pen movement.
|
|
333
|
+
|
|
334
|
+
Amazon documents no pressure-to-width curve, so it was fitted against the only
|
|
335
|
+
ground truth in the file set: the 600 x 800 thumbnails the device renders for
|
|
336
|
+
its own pages. Rendering each sample page at that size and taking the mean
|
|
337
|
+
absolute difference against the matching thumbnail gives, summed over three
|
|
338
|
+
notebooks:
|
|
339
|
+
|
|
340
|
+
| Model | difference |
|
|
341
|
+
|---------------------------------------------------|------------|
|
|
342
|
+
| flat width (no taper) | 14.8711 |
|
|
343
|
+
| width from *absolute* pressure, best fit | 14.8414 |
|
|
344
|
+
| width from pressure *relative to the stroke's mean*| **14.7932**|
|
|
345
|
+
|
|
346
|
+
The third model wins, which says the overall width of a stroke is already
|
|
347
|
+
carried by `nmdl.thickness`, and pressure shapes the taper within it. So the
|
|
348
|
+
width of each segment is
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
level = sqrt(pressure / 4095)
|
|
352
|
+
factor = 1 + TAPER_SPAN * (level - mean level of this stroke)
|
|
353
|
+
width = nominal thickness * factor
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
with `TAPER_SPAN = 1.15`, the flat part of the optimum (1.1 and 1.2 score
|
|
357
|
+
identically, 1.0 and 1.3 are worse).
|
|
358
|
+
|
|
359
|
+
Two cautions. The thumbnail cannot resolve taper directly -- the pen is 0.91 px
|
|
360
|
+
wide there and the taper range is entirely sub-pixel -- so what the measurement
|
|
361
|
+
compares is ink coverage, not shape. And a first attempt at fitting scored every
|
|
362
|
+
taper *worse* than flat, because that parameterisation only ever made strokes
|
|
363
|
+
thinner and the metric was reacting to the missing ink rather than to the taper;
|
|
364
|
+
the numbers above come from mean-preserving variants, where a control sweep of
|
|
365
|
+
plain width multipliers confirms the metric has a real optimum rather than just
|
|
366
|
+
rewarding more ink.
|
|
367
|
+
|
|
368
|
+
Widths are rounded to 8 steps so each stroke becomes a handful of sub-paths
|
|
369
|
+
instead of one per sample point. At 600 dpi (about 8x zoom) 8, 32 and 256 steps
|
|
370
|
+
are indistinguishable, and the runs share their junction points, so no seam
|
|
371
|
+
shows. The highlighter does not taper: its pressure barely moves along a stroke
|
|
372
|
+
while differing widely between strokes, so following it would give each band an
|
|
373
|
+
arbitrary thickness.
|
|
374
|
+
|
|
375
|
+
### 6. Documents (`document.rb`, `krds.rb`)
|
|
376
|
+
|
|
377
|
+
Opening a PDF on the device and writing on it leaves the PDF untouched and
|
|
378
|
+
writes this beside it:
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
documents/HoTT/workbook.pdf
|
|
382
|
+
documents/HoTT/workbook.sdr/<id>/book.kdf the device's copy of the layout
|
|
383
|
+
documents/HoTT/workbook.sdr/<id>/book.sdr/book<hash>.yjr the notes, as annotations
|
|
384
|
+
documents/HoTT/workbook.sdr/kpp_<id>/book.kdf the same layout again
|
|
385
|
+
.notebooks/<id>!!PDOC!!notebook/nbk the ink
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Only the ink and the notes are needed.
|
|
389
|
+
|
|
390
|
+
**The ink** is an ordinary notebook in the format above, one page per PDF page
|
|
391
|
+
written on. Each page records, besides its canvas, the frame the canvas
|
|
392
|
+
occupies on the PDF page as percentages from the top left: `$59` left, `$58`
|
|
393
|
+
top, `$56` width, `$57` height, each with unit `$314`. On a Letter page the
|
|
394
|
+
Scribe's 3:4 canvas is fitted to the width, so it is 103.03% as tall as the
|
|
395
|
+
page and starts 1.515% above it, 12 points past each edge.
|
|
396
|
+
|
|
397
|
+
**The notes** are in the `.yjr` file, a Kindle reader data store:
|
|
398
|
+
|
|
399
|
+
```
|
|
400
|
+
[00 00 00 00 00 1A B1 26] signature
|
|
401
|
+
[long] version
|
|
402
|
+
[int] number of top-level fields
|
|
403
|
+
fields FE, name, typed values and nested fields, FF
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Values carry a type byte: `00` boolean (one byte), `01` int (4 bytes), `02`
|
|
407
|
+
long (8 bytes), `03` string (a flag byte, `01` for null, otherwise a 2-byte
|
|
408
|
+
length and UTF-8), `07` byte. All big-endian. Field names are strings without
|
|
409
|
+
the type byte. That is enough to read the whole file without knowing what any
|
|
410
|
+
field means.
|
|
411
|
+
|
|
412
|
+
Each note is a field `annotation.personal.handwritten_note`, nested inside
|
|
413
|
+
`annotation.cache.object` > `saved.avl.interval.tree`. It holds a start and an
|
|
414
|
+
end position, creation and modification times, a template string, and the id
|
|
415
|
+
of the ink page. The page is in the number after the colon of the position,
|
|
416
|
+
in its upper 16 bits:
|
|
417
|
+
|
|
418
|
+
| Page written on | Position | After the colon | >> 16 |
|
|
419
|
+
|-----------------|-------------------------|-----------------|-------|
|
|
420
|
+
| 1 | `AcYEAAAAAAAA:2` | `0x000002` | 0 |
|
|
421
|
+
| 2 | `AckEAAAAAAAA:65538` | `0x010002` | 1 |
|
|
422
|
+
| 50 | `AVkFAAAAAAAA:3211266` | `0x310002` | 49 |
|
|
423
|
+
| 183 | `AegGAAAAAAAA:11927554` | `0xB60002` | 182 |
|
|
424
|
+
|
|
425
|
+
The lower 16 bits were 2 every time. What the base64 part encodes is not
|
|
426
|
+
known and is not used. It is not an element of the layout in `book.kdf`: for
|
|
427
|
+
page 1 its bytes happen to contain 4, the id of that page's image there, which
|
|
428
|
+
is what a first attempt keyed on, but the other pages do not follow.
|
|
429
|
+
|
|
430
|
+
Pages with a `/Rotate` entry have not been seen yet, and their ink is placed
|
|
431
|
+
as if they were upright.
|
|
432
|
+
|
|
433
|
+
## Library use
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
require "nbk2pdf"
|
|
437
|
+
|
|
438
|
+
notebook = KindleNotebook::Notebook.open("/media/kindle/.notebooks/<uuid>")
|
|
439
|
+
notebook.pages.each do |page|
|
|
440
|
+
puts "#{page.id}: #{page.strokes.size} strokes on #{page.template&.kind || "blank paper"}"
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
KindleNotebook::Converter.new.convert(notebook.path, output: "out/")
|
|
444
|
+
|
|
445
|
+
document = KindleNotebook::Document.open("/media/kindle/documents/HoTT/workbook.pdf")
|
|
446
|
+
document.notes.each { |note| puts "page #{note.page_index + 1}: #{note.page.strokes.size} strokes" }
|
|
447
|
+
KindleNotebook::DocumentRenderer.new.render(document, "workbook.pdf")
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
## Development
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
bundle install
|
|
454
|
+
rake # tests and RuboCop
|
|
455
|
+
rake test
|
|
456
|
+
rake rubocop
|
|
457
|
+
bundle exec exe/nbk2pdf sample-notebooks
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The test suite builds synthetic notebooks from scratch -- `test/test_helper.rb`
|
|
461
|
+
contains a small binary Ion *writer* and an encoder for the stroke format -- so
|
|
462
|
+
the unit tests need no binary fixtures. It also writes the device's reader data
|
|
463
|
+
store format, so the same goes for PDFs written on.
|
|
464
|
+
`test/samples_test.rb` additionally runs against `sample-notebooks/` and is
|
|
465
|
+
skipped when that directory is absent. Documents go under
|
|
466
|
+
`sample-notebooks/device/`, laid out as on the device: `documents/...` with the
|
|
467
|
+
PDF and its `.sdr`, and `.notebooks/<id>!!PDOC!!notebook/`.
|
|
468
|
+
|
|
469
|
+
The stroke decoder is checked against values produced by an independent
|
|
470
|
+
implementation of the same encoding, so a round trip through this project's own
|
|
471
|
+
encoder cannot mask a shared mistake.
|
|
472
|
+
|
|
473
|
+
Rendering is checked by eye against the PNG thumbnails the device itself writes
|
|
474
|
+
to `sample-notebooks/thumbnails/`:
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
bundle exec exe/nbk2pdf -o out sample-notebooks/<uuid>
|
|
478
|
+
pdftoppm -png -r 97 -f 1 -l 1 out/*.pdf page
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
## License
|
|
482
|
+
|
|
483
|
+
GNU General Public License v3.0 or later. See [COPYING](COPYING).
|
|
484
|
+
|
|
485
|
+
The encoding this converter decodes was described publicly by
|
|
486
|
+
[kindle_notebook_pdf_converter](https://github.com/rudolfstepan/kindle_notebook_pdf_converter),
|
|
487
|
+
which in turn traces its stroke decoder to `kfxlib` from John Howell's KFX Input
|
|
488
|
+
plugin for Calibre -- GPL v3. This project is an independent Ruby implementation
|
|
489
|
+
written from that description of the format rather than a port of either, but it
|
|
490
|
+
is released under the GPL so that its lineage stays unambiguous.
|