pocketbook 0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +308 -0
- data/bin/pocketbook +5 -0
- data/bin/test-render +49 -0
- data/lib/pocketbook/book.rb +28 -0
- data/lib/pocketbook/book_renderer/chapter.rb +85 -0
- data/lib/pocketbook/book_renderer/front_matter.rb +27 -0
- data/lib/pocketbook/book_renderer/metadata.rb +70 -0
- data/lib/pocketbook/book_renderer/pdf.rb +42 -0
- data/lib/pocketbook/book_renderer/toc.rb +55 -0
- data/lib/pocketbook/book_renderer.rb +140 -0
- data/lib/pocketbook/book_template.rb +40 -0
- data/lib/pocketbook/cli/options_parser.rb +344 -0
- data/lib/pocketbook/cli/runner.rb +505 -0
- data/lib/pocketbook/cli/watch_command.rb +275 -0
- data/lib/pocketbook/cli.rb +12 -0
- data/lib/pocketbook/core_stylesheet.rb +20 -0
- data/lib/pocketbook/pdf_document.rb +96 -0
- data/lib/pocketbook/render_request.rb +67 -0
- data/lib/pocketbook/styles/core/01_tokens.css +11 -0
- data/lib/pocketbook/styles/core/02_pages.css +72 -0
- data/lib/pocketbook/styles/core/03_layout.css +162 -0
- data/lib/pocketbook/styles/core/04_toc.css +62 -0
- data/lib/pocketbook/styles/core/05_content.css +49 -0
- data/lib/pocketbook/styles/core/06_running.css +48 -0
- data/lib/pocketbook/styles/core/07_print.css +12 -0
- data/lib/pocketbook/theme/manifest.rb +244 -0
- data/lib/pocketbook/theme.rb +268 -0
- data/lib/pocketbook/version.rb +3 -0
- data/lib/pocketbook.rb +29 -0
- data/themes/basic/styles/plain.css +63 -0
- data/themes/basic/template.html.erb +30 -0
- data/themes/basic/theme.yml +8 -0
- data/themes/classic/styles/base.css +250 -0
- data/themes/classic/styles/dark.css +19 -0
- data/themes/classic/styles/light.css +12 -0
- data/themes/classic/styles/sepia.css +17 -0
- data/themes/classic/template.html.erb +72 -0
- data/themes/classic/theme.yml +17 -0
- metadata +136 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
require "listen"
|
|
2
|
+
require "set"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "../book"
|
|
6
|
+
require_relative "../core_stylesheet"
|
|
7
|
+
require_relative "../theme"
|
|
8
|
+
|
|
9
|
+
module Pocketbook
|
|
10
|
+
module CLI
|
|
11
|
+
class Watcher
|
|
12
|
+
WATCHABLE_EXTENSIONS = %w[.md .css .erb .yml .yaml].freeze
|
|
13
|
+
STOP_POLL_INTERVAL = 0.1
|
|
14
|
+
|
|
15
|
+
class ChangeBuffer
|
|
16
|
+
def initialize(debounce_seconds:, poll_interval:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
17
|
+
@debounce_seconds = debounce_seconds
|
|
18
|
+
@poll_interval = poll_interval
|
|
19
|
+
@clock = clock
|
|
20
|
+
@pending_paths = Set.new
|
|
21
|
+
@stopped = false
|
|
22
|
+
@last_change_at = nil
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@condition = ConditionVariable.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add(paths)
|
|
28
|
+
normalized = paths.map { |path| File.expand_path(path) }.uniq
|
|
29
|
+
return if normalized.empty?
|
|
30
|
+
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
normalized.each { |path| @pending_paths << path }
|
|
33
|
+
@last_change_at = @clock.call
|
|
34
|
+
@condition.broadcast
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def next_batch
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
while !@stopped && @pending_paths.empty?
|
|
41
|
+
@condition.wait(@mutex, @poll_interval)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return nil if @stopped
|
|
45
|
+
|
|
46
|
+
loop do
|
|
47
|
+
break if @stopped
|
|
48
|
+
|
|
49
|
+
elapsed = @clock.call - @last_change_at
|
|
50
|
+
remaining = @debounce_seconds - elapsed
|
|
51
|
+
break if remaining <= 0
|
|
52
|
+
|
|
53
|
+
@condition.wait(@mutex, [remaining, @poll_interval].min)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return nil if @stopped
|
|
57
|
+
|
|
58
|
+
paths = @pending_paths.to_a.sort
|
|
59
|
+
@pending_paths.clear
|
|
60
|
+
paths
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stop!
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
return if @stopped
|
|
67
|
+
|
|
68
|
+
@stopped = true
|
|
69
|
+
@condition.broadcast
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def wait_until_stopped
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@condition.wait(@mutex, @poll_interval) until @stopped
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(request:, stdout: $stdout, stderr: $stderr)
|
|
81
|
+
@request = request
|
|
82
|
+
@stdout = stdout
|
|
83
|
+
@stderr = stderr
|
|
84
|
+
@changes = ChangeBuffer.new(
|
|
85
|
+
debounce_seconds: @request.debounce_ms.to_f / 1000.0,
|
|
86
|
+
poll_interval: STOP_POLL_INTERVAL
|
|
87
|
+
)
|
|
88
|
+
@theme_root = nil
|
|
89
|
+
@watch_directories = []
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def run
|
|
93
|
+
theme = build_theme
|
|
94
|
+
@theme_root = theme.root_path
|
|
95
|
+
@watch_directories = compute_watch_directories
|
|
96
|
+
|
|
97
|
+
print_watch_banner
|
|
98
|
+
render(reason: "initial build", preloaded_theme: theme, open_output: @request.open_output)
|
|
99
|
+
|
|
100
|
+
listener = ::Listen.to(*@watch_directories) do |modified, added, removed|
|
|
101
|
+
register_changes(modified + added + removed)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
worker = Thread.new { worker_loop }
|
|
105
|
+
|
|
106
|
+
with_signal_traps do
|
|
107
|
+
listener.start
|
|
108
|
+
@changes.wait_until_stopped
|
|
109
|
+
end
|
|
110
|
+
ensure
|
|
111
|
+
@changes.stop!
|
|
112
|
+
listener&.stop
|
|
113
|
+
worker&.join
|
|
114
|
+
@stdout.puts("[pocketbook] Watch stopped.")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def render(reason:, preloaded_theme: nil, open_output: false)
|
|
120
|
+
start_time = monotonic_time
|
|
121
|
+
output_path = build_book(theme: preloaded_theme).render_to(@request.output_path)
|
|
122
|
+
duration = monotonic_time - start_time
|
|
123
|
+
absolute_output = File.expand_path(output_path)
|
|
124
|
+
|
|
125
|
+
@stdout.puts("[pocketbook] Build succeeded in #{format('%.2f', duration)}s (#{reason}).")
|
|
126
|
+
@stdout.puts("[pocketbook] PDF: #{file_uri(absolute_output)}")
|
|
127
|
+
@stdout.puts("[pocketbook] Open: #{open_command(absolute_output)}")
|
|
128
|
+
if open_output
|
|
129
|
+
if open_with_default_viewer(absolute_output)
|
|
130
|
+
@stdout.puts("[pocketbook] Opened in default viewer.")
|
|
131
|
+
else
|
|
132
|
+
@stderr.puts("[pocketbook] Warning: could not open PDF automatically.")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
@stderr.puts("[pocketbook] Build failed (#{reason}): #{e.message}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_book(theme: nil)
|
|
140
|
+
Book.new(
|
|
141
|
+
inputs: @request.inputs,
|
|
142
|
+
theme: theme || build_theme,
|
|
143
|
+
metadata: @request.metadata
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_theme
|
|
148
|
+
Theme.new(
|
|
149
|
+
theme_path: @request.theme_path,
|
|
150
|
+
style: @request.style,
|
|
151
|
+
template_override: @request.template_override
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def compute_watch_directories
|
|
156
|
+
dirs = Set.new
|
|
157
|
+
@request.inputs.each do |path|
|
|
158
|
+
dirs << File.dirname(File.expand_path(path))
|
|
159
|
+
end
|
|
160
|
+
dirs << @theme_root if @theme_root
|
|
161
|
+
CoreStylesheet.watch_directories.each { |directory| dirs << directory }
|
|
162
|
+
|
|
163
|
+
dirs.to_a.select { |dir| Dir.exist?(dir) }.sort
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def register_changes(paths)
|
|
167
|
+
changed_paths = paths.map { |path| File.expand_path(path) }.uniq
|
|
168
|
+
changed_paths.select! { |path| watchable_path?(path) }
|
|
169
|
+
@changes.add(changed_paths)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def watchable_path?(path)
|
|
173
|
+
WATCHABLE_EXTENSIONS.include?(File.extname(path).downcase)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def worker_loop
|
|
177
|
+
loop do
|
|
178
|
+
changed_paths = @changes.next_batch
|
|
179
|
+
break if changed_paths.nil?
|
|
180
|
+
|
|
181
|
+
render(reason: build_reason(changed_paths))
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_reason(changed_paths)
|
|
186
|
+
if changed_paths.length == 1
|
|
187
|
+
"change detected in #{display_path(changed_paths.first)}"
|
|
188
|
+
else
|
|
189
|
+
"#{changed_paths.length} files changed"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def display_path(path)
|
|
194
|
+
absolute = File.expand_path(path)
|
|
195
|
+
cwd = File.expand_path(Dir.pwd)
|
|
196
|
+
if absolute.start_with?("#{cwd}/")
|
|
197
|
+
absolute[(cwd.length + 1)..]
|
|
198
|
+
else
|
|
199
|
+
absolute
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def with_signal_traps
|
|
204
|
+
previous_int = trap_signal("INT")
|
|
205
|
+
previous_term = trap_signal("TERM")
|
|
206
|
+
|
|
207
|
+
yield
|
|
208
|
+
ensure
|
|
209
|
+
restore_signal("INT", previous_int)
|
|
210
|
+
restore_signal("TERM", previous_term)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def trap_signal(signal_name)
|
|
214
|
+
Signal.trap(signal_name) { request_stop_from_signal }
|
|
215
|
+
rescue ArgumentError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def restore_signal(signal_name, previous_handler)
|
|
220
|
+
return if previous_handler.nil?
|
|
221
|
+
|
|
222
|
+
Signal.trap(signal_name, previous_handler)
|
|
223
|
+
rescue ArgumentError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def request_stop_from_signal
|
|
228
|
+
@changes.stop!
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def file_uri(path)
|
|
232
|
+
"file://#{URI::DEFAULT_PARSER.escape(path)}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def open_with_default_viewer(path)
|
|
236
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
237
|
+
|
|
238
|
+
if host_os =~ /darwin/
|
|
239
|
+
system("open", path)
|
|
240
|
+
elsif host_os =~ /mswin|mingw|cygwin/
|
|
241
|
+
system("cmd", "/c", "start", "", path)
|
|
242
|
+
else
|
|
243
|
+
system("xdg-open", path)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def open_command(path)
|
|
248
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
249
|
+
if host_os =~ /darwin/
|
|
250
|
+
"open \"#{path}\""
|
|
251
|
+
elsif host_os =~ /mswin|mingw|cygwin/
|
|
252
|
+
"cmd /c start \"\" \"#{path}\""
|
|
253
|
+
else
|
|
254
|
+
"xdg-open \"#{path}\""
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def monotonic_time
|
|
259
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def print_watch_banner
|
|
263
|
+
@stdout.puts("[pocketbook] Watch mode enabled.")
|
|
264
|
+
@stdout.puts("[pocketbook] Debounce window: #{@request.debounce_ms}ms")
|
|
265
|
+
@stdout.puts("[pocketbook] Watching #{@watch_directories.length} directories:")
|
|
266
|
+
@watch_directories.each do |directory|
|
|
267
|
+
@stdout.puts("[pocketbook] - #{display_path(directory)}")
|
|
268
|
+
end
|
|
269
|
+
@stdout.puts("[pocketbook] Press Ctrl+C to stop.")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
WatchCommand = Watcher
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "cli/options_parser"
|
|
2
|
+
require_relative "cli/runner"
|
|
3
|
+
|
|
4
|
+
module Pocketbook
|
|
5
|
+
module CLI
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def run(argv = ARGV, stdout: $stdout, stderr: $stderr)
|
|
9
|
+
Runner.new(stdout: stdout, stderr: stderr).run(argv)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Pocketbook
|
|
2
|
+
class CoreStylesheet
|
|
3
|
+
CORE_DIRECTORY = File.expand_path("styles/core", __dir__)
|
|
4
|
+
|
|
5
|
+
def self.load
|
|
6
|
+
paths = css_paths
|
|
7
|
+
raise ArgumentError, "No core stylesheets found in #{CORE_DIRECTORY}" if paths.empty?
|
|
8
|
+
|
|
9
|
+
paths.map { |path| File.read(path) }.join("\n\n")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.watch_directories
|
|
13
|
+
css_paths.map { |path| File.dirname(path) }.uniq
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.css_paths
|
|
17
|
+
Dir.glob(File.join(CORE_DIRECTORY, "*.css")).sort
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "hexapdf"
|
|
2
|
+
|
|
3
|
+
module Pocketbook
|
|
4
|
+
class PdfDocument
|
|
5
|
+
def self.open(path)
|
|
6
|
+
new(document: HexaPDF::Document.open(path))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(document:)
|
|
10
|
+
@document = document
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def page_count
|
|
14
|
+
@document.pages.count
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def toc_page_numbers(anchor_ids)
|
|
18
|
+
return {} if anchor_ids.empty?
|
|
19
|
+
|
|
20
|
+
page_number_by_reference = extract_page_numbers
|
|
21
|
+
destination_reference_by_id = named_destinations
|
|
22
|
+
|
|
23
|
+
anchor_ids.each_with_object({}) do |anchor_id, output|
|
|
24
|
+
page_reference = destination_reference_by_id[anchor_id]
|
|
25
|
+
next if page_reference.nil?
|
|
26
|
+
|
|
27
|
+
page_number = page_number_by_reference[page_reference]
|
|
28
|
+
next if page_number.nil?
|
|
29
|
+
|
|
30
|
+
output[anchor_id] = page_number
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def named_destinations
|
|
35
|
+
{}.tap do |output|
|
|
36
|
+
extract_old_style_destinations(output)
|
|
37
|
+
extract_name_tree_destinations(output)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def extract_page_numbers
|
|
44
|
+
@document.pages.each_with_index.each_with_object({}) do |(page, index), output|
|
|
45
|
+
output[[page.oid, page.gen]] = index + 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract_old_style_destinations(output)
|
|
50
|
+
return unless @document.catalog.key?(:Dests)
|
|
51
|
+
|
|
52
|
+
@document.catalog[:Dests].each do |target, destination|
|
|
53
|
+
target_id = normalize_destination_id(target)
|
|
54
|
+
page_reference = destination_page_reference(destination)
|
|
55
|
+
next if target_id.nil? || page_reference.nil?
|
|
56
|
+
|
|
57
|
+
output[target_id] ||= page_reference
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_name_tree_destinations(output)
|
|
62
|
+
names = @document.catalog[:Names]
|
|
63
|
+
return if names.nil? || !names.key?(:Dests)
|
|
64
|
+
|
|
65
|
+
names[:Dests].each_entry do |target, destination|
|
|
66
|
+
target_id = normalize_destination_id(target)
|
|
67
|
+
page_reference = destination_page_reference(destination)
|
|
68
|
+
next if target_id.nil? || page_reference.nil?
|
|
69
|
+
|
|
70
|
+
output[target_id] ||= page_reference
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def normalize_destination_id(target)
|
|
75
|
+
return target.to_s if target.is_a?(Symbol)
|
|
76
|
+
return target if target.is_a?(String)
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def destination_page_reference(destination)
|
|
82
|
+
value = if destination.respond_to?(:key?) && destination.key?(:D)
|
|
83
|
+
destination[:D]
|
|
84
|
+
else
|
|
85
|
+
destination
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return nil unless value.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
page = value.first
|
|
91
|
+
return nil unless page.respond_to?(:oid) && page.respond_to?(:gen)
|
|
92
|
+
|
|
93
|
+
[page.oid, page.gen]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Pocketbook
|
|
2
|
+
class RenderRequest
|
|
3
|
+
attr_reader :inputs, :theme_path, :style, :template_override, :output_path, :metadata, :debounce_ms, :diagnostics, :open_output
|
|
4
|
+
|
|
5
|
+
def initialize(
|
|
6
|
+
inputs:,
|
|
7
|
+
theme_path:,
|
|
8
|
+
style: nil,
|
|
9
|
+
template_override: nil,
|
|
10
|
+
output_path:,
|
|
11
|
+
metadata: {},
|
|
12
|
+
debounce_ms: 350,
|
|
13
|
+
diagnostics: false,
|
|
14
|
+
open_output: false
|
|
15
|
+
)
|
|
16
|
+
@inputs = normalize_inputs(inputs)
|
|
17
|
+
@theme_path = normalize_required_string(theme_path, "theme_path")
|
|
18
|
+
@style = normalize_optional_string(style)
|
|
19
|
+
@template_override = normalize_optional_string(template_override)
|
|
20
|
+
@output_path = normalize_required_string(output_path, "output_path")
|
|
21
|
+
@metadata = symbolize_keys(metadata || {}).freeze
|
|
22
|
+
@debounce_ms = normalize_debounce_ms(debounce_ms)
|
|
23
|
+
@diagnostics = !!diagnostics
|
|
24
|
+
@open_output = !!open_output
|
|
25
|
+
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def normalize_inputs(values)
|
|
32
|
+
items = Array(values).map { |value| normalize_required_string(value, "inputs") }
|
|
33
|
+
raise ArgumentError, "At least one markdown input file is required" if items.empty?
|
|
34
|
+
|
|
35
|
+
items.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_required_string(value, label)
|
|
39
|
+
normalized = value.to_s.strip
|
|
40
|
+
raise ArgumentError, "#{label} is required" if normalized.empty?
|
|
41
|
+
|
|
42
|
+
normalized
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_optional_string(value)
|
|
46
|
+
return nil if value.nil?
|
|
47
|
+
|
|
48
|
+
normalized = value.to_s.strip
|
|
49
|
+
return nil if normalized.empty?
|
|
50
|
+
|
|
51
|
+
normalized
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize_debounce_ms(value)
|
|
55
|
+
normalized = Integer(value)
|
|
56
|
+
raise ArgumentError, "debounce_ms must be zero or positive" if normalized.negative?
|
|
57
|
+
|
|
58
|
+
normalized
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def symbolize_keys(hash)
|
|
62
|
+
hash.each_with_object({}) do |(key, value), output|
|
|
63
|
+
output[key.to_sym] = value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--book-size: 6in 9in;
|
|
3
|
+
--paper: #ffffff;
|
|
4
|
+
--text-color: #161616;
|
|
5
|
+
--folio-color: #5f5f5f;
|
|
6
|
+
--cover-bg: #151515;
|
|
7
|
+
--cover-fg: #f5f5f5;
|
|
8
|
+
--accent: #b88539;
|
|
9
|
+
--font-body: "Iowan Old Style", "Palatino Linotype", serif;
|
|
10
|
+
--font-display: "Avenir Next", "Helvetica Neue", sans-serif;
|
|
11
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
@page {
|
|
2
|
+
size: var(--book-size);
|
|
3
|
+
background: var(--paper);
|
|
4
|
+
margin-top: 18mm;
|
|
5
|
+
margin-right: 16mm;
|
|
6
|
+
margin-bottom: 22mm;
|
|
7
|
+
margin-left: 16mm;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@page :left {
|
|
11
|
+
background: var(--paper);
|
|
12
|
+
margin-left: 20mm;
|
|
13
|
+
margin-right: 14mm;
|
|
14
|
+
|
|
15
|
+
@top-left {
|
|
16
|
+
content: var(--book-title-label);
|
|
17
|
+
font-family: var(--font-display);
|
|
18
|
+
font-size: 8.5pt;
|
|
19
|
+
letter-spacing: 0.04em;
|
|
20
|
+
color: var(--folio-color);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@bottom-left {
|
|
24
|
+
content: var(--publisher-label);
|
|
25
|
+
font-family: var(--font-display);
|
|
26
|
+
font-size: 8.5pt;
|
|
27
|
+
color: var(--folio-color);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@bottom-right {
|
|
31
|
+
content: counter(page);
|
|
32
|
+
font-family: var(--font-display);
|
|
33
|
+
font-size: 8.5pt;
|
|
34
|
+
color: var(--folio-color);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@page :right {
|
|
39
|
+
background: var(--paper);
|
|
40
|
+
margin-left: 14mm;
|
|
41
|
+
margin-right: 20mm;
|
|
42
|
+
|
|
43
|
+
@top-right {
|
|
44
|
+
content: var(--book-title-label);
|
|
45
|
+
font-family: var(--font-display);
|
|
46
|
+
font-size: 8.5pt;
|
|
47
|
+
letter-spacing: 0.04em;
|
|
48
|
+
color: var(--folio-color);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@bottom-left {
|
|
52
|
+
content: var(--publisher-label);
|
|
53
|
+
font-family: var(--font-display);
|
|
54
|
+
font-size: 8.5pt;
|
|
55
|
+
color: var(--folio-color);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@bottom-right {
|
|
59
|
+
content: counter(page);
|
|
60
|
+
font-family: var(--font-display);
|
|
61
|
+
font-size: 8.5pt;
|
|
62
|
+
color: var(--folio-color);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@page cover {
|
|
67
|
+
margin: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@page backcover {
|
|
71
|
+
margin: 0;
|
|
72
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
html,
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
color: var(--text-color);
|
|
10
|
+
background: var(--paper);
|
|
11
|
+
font-family: var(--font-body);
|
|
12
|
+
font-size: 11pt;
|
|
13
|
+
line-height: 1.55;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.cover,
|
|
17
|
+
.backcover {
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
display: grid;
|
|
20
|
+
position: relative;
|
|
21
|
+
z-index: 30;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.cover {
|
|
25
|
+
page: cover;
|
|
26
|
+
break-after: page;
|
|
27
|
+
color: var(--cover-fg);
|
|
28
|
+
background: var(--cover-bg);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.backcover {
|
|
32
|
+
page: backcover;
|
|
33
|
+
break-before: page;
|
|
34
|
+
color: var(--cover-fg);
|
|
35
|
+
background: #222;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.cover-inner,
|
|
39
|
+
.backcover-inner {
|
|
40
|
+
align-self: stretch;
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: 0.8rem;
|
|
43
|
+
padding: 16mm;
|
|
44
|
+
align-content: center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.cover-title {
|
|
48
|
+
margin: 0;
|
|
49
|
+
font-family: var(--font-display);
|
|
50
|
+
font-size: 2.1rem;
|
|
51
|
+
line-height: 1.1;
|
|
52
|
+
letter-spacing: 0.02em;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.cover-subtitle {
|
|
56
|
+
margin: 0;
|
|
57
|
+
opacity: 0.9;
|
|
58
|
+
font-size: 1rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.cover-author {
|
|
62
|
+
margin-top: 1rem;
|
|
63
|
+
margin-bottom: 0;
|
|
64
|
+
font-family: var(--font-display);
|
|
65
|
+
font-size: 0.95rem;
|
|
66
|
+
letter-spacing: 0.05em;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.backcover-title {
|
|
71
|
+
margin: 0;
|
|
72
|
+
font-family: var(--font-display);
|
|
73
|
+
color: var(--accent);
|
|
74
|
+
letter-spacing: 0.04em;
|
|
75
|
+
text-transform: uppercase;
|
|
76
|
+
font-size: 0.8rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.backcover-copy {
|
|
80
|
+
margin: 0;
|
|
81
|
+
font-size: 0.95rem;
|
|
82
|
+
opacity: 0.95;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.toc {
|
|
86
|
+
break-before: page;
|
|
87
|
+
break-after: page;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.toc,
|
|
91
|
+
.chapter {
|
|
92
|
+
padding: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@media screen {
|
|
96
|
+
html,
|
|
97
|
+
body {
|
|
98
|
+
min-height: 100%;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
html {
|
|
102
|
+
scroll-snap-type: y mandatory;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.cover,
|
|
106
|
+
.toc,
|
|
107
|
+
.book-content,
|
|
108
|
+
.chapter,
|
|
109
|
+
.backcover,
|
|
110
|
+
[data-pocketbook-page-spacer],
|
|
111
|
+
[data-pocketbook-flow-spacer] {
|
|
112
|
+
scroll-snap-align: start;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
[data-pocketbook-page-spacer],
|
|
116
|
+
[data-pocketbook-flow-spacer] {
|
|
117
|
+
scroll-snap-stop: always;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
:root {
|
|
121
|
+
--pocketbook-screen-left-margin: 16mm;
|
|
122
|
+
--pocketbook-screen-right-margin: 16mm;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
:root[data-pocketbook-page-parity="left"] {
|
|
126
|
+
--pocketbook-screen-left-margin: 20mm;
|
|
127
|
+
--pocketbook-screen-right-margin: 14mm;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
:root[data-pocketbook-page-parity="right"] {
|
|
131
|
+
--pocketbook-screen-left-margin: 14mm;
|
|
132
|
+
--pocketbook-screen-right-margin: 20mm;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.toc,
|
|
136
|
+
.chapter {
|
|
137
|
+
/* Screen preview uses fixed running header/footer overlays. */
|
|
138
|
+
padding-top: 24mm;
|
|
139
|
+
padding-right: var(--pocketbook-screen-right-margin);
|
|
140
|
+
padding-bottom: 28mm;
|
|
141
|
+
padding-left: var(--pocketbook-screen-left-margin);
|
|
142
|
+
min-height: 100vh;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.running-header,
|
|
146
|
+
.running-footer {
|
|
147
|
+
padding-left: var(--pocketbook-screen-left-margin);
|
|
148
|
+
padding-right: var(--pocketbook-screen-right-margin);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
:root[data-pocketbook-page-parity="left"] .running-header {
|
|
152
|
+
justify-content: flex-start;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
:root[data-pocketbook-page-parity="right"] .running-header {
|
|
156
|
+
justify-content: flex-end;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.running-header-chapter {
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
}
|