nbk2pdf 1.0.1 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64fd3b6e1a6523298f069b406c0cf7a3b0b1d0df87cf843c665f5cb10b31f638
4
- data.tar.gz: 022be27e074f4f6d0301ea10d10666d461aabdfe4aca1d8bba8751d3ffd23912
3
+ metadata.gz: 2993302368291aeb45035687bc5bdd501beef69c948910e7f173ea6db799383e
4
+ data.tar.gz: 720767d5e65bfef5e979661f09222d4af55bdcc62da60b5527dd556de07c2ea3
5
5
  SHA512:
6
- metadata.gz: 4d8f4247ffe944e73d631dcadc50f01fe463f0c79296b20e0fca9dc2cdbeb1c4c8eeea4b7a1b88bc1729aa8dfddc961b0ac37c0f3861b92b36f892e9dab48646
7
- data.tar.gz: bf6aacb7f72b3b4fd86f48f005be933e2f4826f3a3997cacf31a9a1c66489975e0e16b2ef41f09780bc3d4d905e0f27226f851b75bc8e9ff87bc06446e67d814
6
+ metadata.gz: 91a2ab63c1932539554505defb38810e049adeae2c6c352ca8583d11badd1e5894cfaa6a12adf73656a772a23f33d6bf3609c6e0b2ae48b9aedb2693a9774cb3
7
+ data.tar.gz: 8b28c36986aaefb0a3478c91fe49dbd221ca4e36cb05af45cbd6475f46f2e472cc5564738eab6637207dbab9c9eb22b8d3f6ef0b6e915be659452547a2b08efd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.1.0] - 2026-10-21
8
+
9
+ ### Added
10
+
11
+ - `--two-up` prints notebook pages in pairs, side by side on landscape paper,
12
+ with margins so the pages do not touch.
13
+ - Two-up and booklet pages are numbered in light grey; `--no-page-numbers`
14
+ leaves the numbers out.
15
+ - `--booklet` lays notebook pages out to fold into a booklet, printed
16
+ double-sided on the long edge; `--signature` splits it into signatures of
17
+ that many sheets.
18
+ - `--flip-on-edge short` lays a booklet out for a printer that flips on the
19
+ short edge.
20
+
7
21
  ## [1.0.1] - 2026-10-20
8
22
 
9
23
  ### Fixed
data/README.md CHANGED
@@ -202,6 +202,11 @@ Options:
202
202
  -t, --titles FILE JSON/YAML map of notebook uuid to the name you gave it
203
203
  --[no-]template Draw the notebook's ruled background (default: yes)
204
204
  -p, --paper SIZE Page size: letter (default), a4, native, ...
205
+ -2, --two-up Put notebook pages in pairs side by side on landscape paper
206
+ -b, --booklet Lay notebook pages out to fold into a booklet
207
+ --signature SHEETS Fold the --booklet in signatures of SHEETS sheets each
208
+ --flip-on-edge EDGE Edge the printer flips a --booklet on: long (default) or short
209
+ --[no-]page-numbers Number the pages when --two-up or --booklet (default: yes)
205
210
  -a, --annotations Add ink to a PDF as annotations, not drawn into the page
206
211
  -q, --quiet Only report errors
207
212
  -h, --help Show this message
@@ -325,6 +330,47 @@ never tells a viewer whether scaling is allowed. If a print dialog greys out its
325
330
  scale field, that is the viewer's own setting -- in Evince, the manual scale is
326
331
  only editable once *Page Scaling* is set to *None*.
327
332
 
333
+ #### Two pages per sheet
334
+
335
+ `--two-up` puts notebook pages in pairs, side by side, on the paper turned
336
+ landscape. Each page stays portrait and is scaled to fit its half. An 18 pt
337
+ margin is kept around the sheet and between the two pages, so they never
338
+ touch. An odd last page sits alone on the left of its sheet.
339
+
340
+ ```
341
+ nbk2pdf --two-up notebook/ # two pages on landscape Letter
342
+ nbk2pdf --two-up --paper a4 notebook/
343
+ nbk2pdf --two-up --paper native notebook/ # a sheet just wide enough for both
344
+ ```
345
+
346
+ On Letter each page is scaled to 83% of the device's size.
347
+
348
+ Each page is numbered in light grey, centred under it. `--no-page-numbers`
349
+ leaves the numbers out. Pages printed one per sheet are not numbered.
350
+
351
+ #### Booklets
352
+
353
+ `--booklet` lays the pages out two-up in the order that folds into a booklet.
354
+ Print the file double-sided at 100% scale, flipping on the **long** edge. The
355
+ back of every sheet is drawn upside down so that it comes out the right way up
356
+ from a long-edge duplexer. For a printer that flips on the short edge, pass
357
+ `--flip-on-edge short` and the backs are drawn the right way up.
358
+
359
+ ```
360
+ nbk2pdf --booklet notebook/ # every sheet folded together
361
+ nbk2pdf --booklet --signature 4 notebook/ # signatures of 4 sheets, 16 pages
362
+ nbk2pdf --booklet --flip-on-edge short notebook/
363
+ ```
364
+
365
+ By default all the sheets are folded together down the middle into one
366
+ booklet, padded to whole sheets with blank pages at the end. A thick notebook
367
+ does not fold well as one stack, so `--signature` splits it into signatures of
368
+ that many sheets each. Fold each signature on its own, then stack them in order
369
+ to sew or staple them. The last signature is padded with blank pages to the
370
+ same thickness as the others, so the spine stays even.
371
+
372
+ Blank pages are not numbered, so the numbers follow the notebook.
373
+
328
374
  #### Stroke taper
329
375
 
330
376
  The pen records a 12-bit pressure per point, and it varies a lot inside a single
@@ -52,7 +52,7 @@ module KindleNotebook
52
52
  @out = out
53
53
  @err = err
54
54
  @device = device
55
- @options = { template: true, quiet: false, annotations: false }
55
+ @options = { template: true, quiet: false, annotations: false, two_up: false, page_numbers: true }
56
56
  end
57
57
 
58
58
  # Runs the command and returns the process exit status.
@@ -105,12 +105,34 @@ module KindleNotebook
105
105
  opts.on("-p", "--paper SIZE", "Page size: letter (default), a4, native, ...") do |size|
106
106
  options[:paper] = size
107
107
  end
108
+ define_layout_options(opts)
108
109
  opts.on("-a", "--annotations", "Add ink to a PDF as annotations, not drawn into the page") do
109
110
  options[:annotations] = true
110
111
  end
111
112
  opts.on("-q", "--quiet", "Only report errors") { options[:quiet] = true }
112
113
  end
113
114
 
115
+ def define_layout_options(opts)
116
+ opts.on("-2", "--two-up", "Put notebook pages in pairs side by side on landscape paper") do
117
+ options[:two_up] = true
118
+ end
119
+ opts.on("-b", "--booklet", "Lay notebook pages out to fold into a booklet") do
120
+ options[:booklet] ||= {}
121
+ end
122
+ opts.on("--signature SHEETS", Integer, "Fold the --booklet in signatures of SHEETS sheets each") do |sheets|
123
+ raise OptionParser::InvalidArgument, "--signature #{sheets}" unless sheets.positive?
124
+
125
+ (options[:booklet] ||= {})[:signature] = sheets
126
+ end
127
+ opts.on("--flip-on-edge EDGE", %w[long short],
128
+ "Edge the printer flips a --booklet on: long (default) or short") do |edge|
129
+ (options[:booklet] ||= {})[:flip] = edge.to_sym
130
+ end
131
+ opts.on("--[no-]page-numbers", "Number the pages when --two-up or --booklet (default: yes)") do |number|
132
+ options[:page_numbers] = number
133
+ end
134
+ end
135
+
114
136
  def convert(paths)
115
137
  sources = paths.flat_map { |path| Converter.document?(path) ? [File.expand_path(path)] : Notebook.find(path) }
116
138
  sources = with_documents(sources)
@@ -231,7 +253,9 @@ module KindleNotebook
231
253
  def converter
232
254
  @converter ||= Converter.new(titles: titles, render_template: options[:template],
233
255
  paper: PdfRenderer.paper(options[:paper]),
234
- annotations: options[:annotations])
256
+ annotations: options[:annotations],
257
+ two_up: options[:two_up], page_numbers: options[:page_numbers],
258
+ booklet: options[:booklet])
235
259
  end
236
260
 
237
261
  def titles = @titles ||= Titles.load(options[:titles])
@@ -16,10 +16,10 @@ module KindleNotebook
16
16
  # +titles+ maps a notebook uuid (or book id) to the name you know it by on
17
17
  # the device, since standalone notebooks do not store their own title.
18
18
  # +annotations+ adds ink to a document as annotations rather than drawing
19
- # it into the page.
20
- def initialize(titles: {}, render_template: true, paper: PdfRenderer.paper, annotations: false)
19
+ # it into the page. The remaining options go to PdfRenderer.
20
+ def initialize(titles: {}, annotations: false, **rendering)
21
21
  @titles = titles
22
- @renderer = PdfRenderer.new(render_template: render_template, paper: paper)
22
+ @renderer = PdfRenderer.new(**rendering)
23
23
  @documents = DocumentRenderer.new(annotations: annotations)
24
24
  @written = {}
25
25
  end
@@ -44,20 +44,72 @@ module KindleNotebook
44
44
  # sub-paths instead of one per sample point.
45
45
  WIDTH_LEVELS = 8
46
46
 
47
+ # Two-up sheets keep this much space around and between their pages, so
48
+ # neither page touches the other or the edge of the sheet.
49
+ SHEET_MARGIN = 18
50
+
51
+ # Page numbers sit centred under each page of a two-up sheet, in a band
52
+ # reserved below it.
53
+ NUMBER_SIZE = 8
54
+ NUMBER_BAND = NUMBER_SIZE + 4
55
+ NUMBER_COLOR = "A0A0A0"
56
+
47
57
  # +paper+ is a resolved [width, height], or nil for the device's own size.
48
- def initialize(render_template: true, paper: self.class.paper)
58
+ # +two_up+ puts pages in pairs side by side on the paper turned landscape;
59
+ # +page_numbers+ then numbers each of them. +booklet+, when given, is a
60
+ # hash of the options PdfRenderer.booklet takes; it implies +two_up+.
61
+ def initialize(render_template: true, paper: self.class.paper, two_up: false, page_numbers: true,
62
+ booklet: nil)
49
63
  @render_template = render_template
50
64
  @paper = paper
65
+ @two_up = two_up || !booklet.nil?
66
+ @page_numbers = page_numbers
67
+ @booklet = booklet
68
+ end
69
+
70
+ # The sides of a booklet's sheets in print order, each as
71
+ # [[left, right], turned] taken from +items+, nil for a blank side of a
72
+ # leaf. All the items are folded together in one signature of whole
73
+ # sheets. Given +signature+, they are split into signatures of that many
74
+ # sheets instead, each folded on its own and stacked in order; the last is
75
+ # padded with blanks to the same thickness as the others, unless
76
+ # everything fits in one, which then shrinks to whole sheets.
77
+ #
78
+ # +flip+ is the edge the printer turns the sheet over on. Flipping on the
79
+ # :long edge lands the back of each sheet upside down, so it is drawn
80
+ # turned and with its halves swapped; on the :short edge it is drawn as is.
81
+ def self.booklet(items, signature: nil, flip: :long)
82
+ whole = ((items.size + 3) / 4) * 4
83
+ capacity = signature ? [signature * 4, whole].min : whole
84
+ (0...items.size).step(capacity).flat_map do |first|
85
+ fold_order(capacity).map { |local| items[first + local] }
86
+ .each_slice(4).flat_map do |front_left, front_right, back_left, back_right|
87
+ back = flip == :short ? [[back_left, back_right], false] : [[back_right, back_left], true]
88
+ [[[front_left, front_right], false], back]
89
+ end
90
+ end
51
91
  end
52
92
 
93
+ # Local page indices of one signature in print order, four per sheet: the
94
+ # front carries the last page on the left and the first on the right, the
95
+ # back the next two in.
96
+ def self.fold_order(capacity)
97
+ (0...(capacity / 2)).step(2).flat_map do |left|
98
+ right = capacity - 1 - left
99
+ [right, left, left + 1, right - 1]
100
+ end
101
+ end
102
+ private_class_method :fold_order
103
+
53
104
  # +destination+ may be a path or any IO that responds to #write.
54
105
  def render(pages, destination, title: nil)
55
106
  raise Error, "notebook has no pages" if pages.empty?
56
107
 
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)
108
+ sides = layout(pages)
109
+ document = new_document(sheet_size(side_page(sides.first, pages)), title)
110
+ sides.each_with_index do |side, index|
111
+ document.start_new_page(size: sheet_size(side_page(side, pages)), margin: 0) unless index.zero?
112
+ draw_side(document, side, side_page(side, pages))
61
113
  end
62
114
  emit(document, destination)
63
115
  end
@@ -85,9 +137,9 @@ module KindleNotebook
85
137
 
86
138
  private
87
139
 
88
- def new_document(first_page, title)
140
+ def new_document(size, title)
89
141
  Prawn::Document.new(
90
- page_size: page_size(first_page),
142
+ page_size: size,
91
143
  margin: 0,
92
144
  info: {
93
145
  Title: title,
@@ -106,28 +158,94 @@ module KindleNotebook
106
158
  destination
107
159
  end
108
160
 
109
- def page_size(page) = @paper || [page.width_in_points, page.height_in_points]
161
+ # Each side of a sheet is [slots, turned], a slot being [page, number], or
162
+ # nil where a booklet leaves a blank.
163
+ def layout(pages)
164
+ slots = pages.each_with_index.map { |page, index| [page, index + 1] }
165
+ return slots.map { |slot| [[slot], false] } unless @two_up
166
+ return slots.each_slice(2).map { |pair| [pair, false] } unless @booklet
167
+
168
+ self.class.booklet(slots, **@booklet)
169
+ end
170
+
171
+ # The page that sets a side's size. A side left wholly blank takes the size
172
+ # of the notebook's first page.
173
+ def side_page(side, pages) = side.first.compact.first&.first || pages.first
174
+
175
+ # A two-up sheet on the device's own size is just wide enough for its two
176
+ # pages at full size, plus the margins and the band for numbers.
177
+ def sheet_size(page)
178
+ return @paper || [page.width_in_points, page.height_in_points] unless @two_up
179
+ return [@paper.max, @paper.min] if @paper
180
+
181
+ [(page.width_in_points * 2) + (SHEET_MARGIN * 3),
182
+ page.height_in_points + (SHEET_MARGIN * 2) + number_band]
183
+ end
184
+
185
+ def draw_side(document, side, page)
186
+ return draw_two_up(document, side, page) if @two_up
187
+
188
+ draw_page(document, page, @paper && [0, 0, *@paper])
189
+ end
190
+
191
+ def number_band = @page_numbers ? NUMBER_BAND : 0
192
+
193
+ # A pair of pages, left and right, each fitted into its half of the sheet.
194
+ # An odd last page sits alone on the left.
195
+ def draw_two_up(document, (slots, turned), reference)
196
+ sheet_width, sheet_height = sheet_size(reference)
197
+ width = (sheet_width - (SHEET_MARGIN * 3)) / 2.0
198
+ height = sheet_height - (SHEET_MARGIN * 2) - number_band
199
+ upside_down(document, turned, sheet_width, sheet_height) do
200
+ slots.each_with_index do |(page, number), column|
201
+ next if page.nil?
202
+
203
+ box = [SHEET_MARGIN + (column * (width + SHEET_MARGIN)), SHEET_MARGIN + number_band, width, height]
204
+ draw_page(document, page, box)
205
+ draw_number(document, page, box, number) if @page_numbers
206
+ end
207
+ end
208
+ end
110
209
 
111
- def draw_page(document, page)
210
+ def upside_down(document, turned, width, height, &)
211
+ turned ? document.rotate(180, origin: [width / 2.0, height / 2.0], &) : yield
212
+ end
213
+
214
+ def draw_number(document, page, box, number)
215
+ scale, x, y = placement(page, box)
216
+ text = number.to_s
217
+ left = x + (((page.width_in_points * scale) - document.width_of(text, size: NUMBER_SIZE)) / 2)
218
+ document.fill_color(NUMBER_COLOR)
219
+ document.draw_text(text, size: NUMBER_SIZE, at: [left, y - NUMBER_BAND])
220
+ end
221
+
222
+ # +box+ is [x, y, width, height] to fit the page into, or nil to draw it
223
+ # at the device's own size.
224
+ def draw_page(document, page, box)
112
225
  document.cap_style(:round)
113
226
  document.join_style(:round)
114
- fitted(document, page) do
227
+ fitted(document, page, box) do
115
228
  draw_template(document, page) if @render_template
116
229
  page.strokes.each { |stroke| draw_stroke(document, page, stroke) }
117
230
  end
118
231
  end
119
232
 
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?
233
+ def fitted(document, page, box, &)
234
+ return yield if box.nil?
124
235
 
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, &)
236
+ scale, x, y = placement(page, box)
237
+ document.transformation_matrix(scale, 0, 0, scale, x, y, &)
238
+ end
239
+
240
+ # The canvas is scaled to fit +box+ and centred in it, keeping its
241
+ # proportions, so nothing is stretched or cropped. Answers the scale and
242
+ # where the canvas's lower left corner lands.
243
+ def placement(page, box)
244
+ x, y, width, height = box
245
+ scale = [width / page.width_in_points, height / page.height_in_points].min
246
+ [scale,
247
+ x + ((width - (page.width_in_points * scale)) / 2),
248
+ y + ((height - (page.height_in_points * scale)) / 2)]
131
249
  end
132
250
 
133
251
  def draw_template(document, page)
@@ -3,7 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  module KindleNotebook
6
- VERSION = "1.0.1"
6
+ VERSION = "1.1.0"
7
7
 
8
8
  # The name this library is published and invoked under.
9
9
  PROGRAM = "nbk2pdf"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nbk2pdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Avseyev