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