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
|
@@ -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
|