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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +308 -0
  4. data/bin/pocketbook +5 -0
  5. data/bin/test-render +49 -0
  6. data/lib/pocketbook/book.rb +28 -0
  7. data/lib/pocketbook/book_renderer/chapter.rb +85 -0
  8. data/lib/pocketbook/book_renderer/front_matter.rb +27 -0
  9. data/lib/pocketbook/book_renderer/metadata.rb +70 -0
  10. data/lib/pocketbook/book_renderer/pdf.rb +42 -0
  11. data/lib/pocketbook/book_renderer/toc.rb +55 -0
  12. data/lib/pocketbook/book_renderer.rb +140 -0
  13. data/lib/pocketbook/book_template.rb +40 -0
  14. data/lib/pocketbook/cli/options_parser.rb +344 -0
  15. data/lib/pocketbook/cli/runner.rb +505 -0
  16. data/lib/pocketbook/cli/watch_command.rb +275 -0
  17. data/lib/pocketbook/cli.rb +12 -0
  18. data/lib/pocketbook/core_stylesheet.rb +20 -0
  19. data/lib/pocketbook/pdf_document.rb +96 -0
  20. data/lib/pocketbook/render_request.rb +67 -0
  21. data/lib/pocketbook/styles/core/01_tokens.css +11 -0
  22. data/lib/pocketbook/styles/core/02_pages.css +72 -0
  23. data/lib/pocketbook/styles/core/03_layout.css +162 -0
  24. data/lib/pocketbook/styles/core/04_toc.css +62 -0
  25. data/lib/pocketbook/styles/core/05_content.css +49 -0
  26. data/lib/pocketbook/styles/core/06_running.css +48 -0
  27. data/lib/pocketbook/styles/core/07_print.css +12 -0
  28. data/lib/pocketbook/theme/manifest.rb +244 -0
  29. data/lib/pocketbook/theme.rb +268 -0
  30. data/lib/pocketbook/version.rb +3 -0
  31. data/lib/pocketbook.rb +29 -0
  32. data/themes/basic/styles/plain.css +63 -0
  33. data/themes/basic/template.html.erb +30 -0
  34. data/themes/basic/theme.yml +8 -0
  35. data/themes/classic/styles/base.css +250 -0
  36. data/themes/classic/styles/dark.css +19 -0
  37. data/themes/classic/styles/light.css +12 -0
  38. data/themes/classic/styles/sepia.css +17 -0
  39. data/themes/classic/template.html.erb +72 -0
  40. data/themes/classic/theme.yml +17 -0
  41. metadata +136 -0
@@ -0,0 +1,62 @@
1
+ .toc-inner {
2
+ padding-top: 0;
3
+ }
4
+
5
+ .toc-heading {
6
+ margin-top: 0;
7
+ margin-bottom: 1.1rem;
8
+ font-family: var(--font-display);
9
+ font-size: 1.4rem;
10
+ }
11
+
12
+ .toc-list {
13
+ margin: 0;
14
+ padding: 0;
15
+ list-style: none;
16
+ }
17
+
18
+ .toc-item + .toc-item {
19
+ margin-top: 0.45rem;
20
+ }
21
+
22
+ .toc-link {
23
+ color: inherit;
24
+ text-decoration: none;
25
+ display: flex;
26
+ align-items: baseline;
27
+ gap: 0.45rem;
28
+ }
29
+
30
+ .toc-title {
31
+ font-family: var(--font-display);
32
+ }
33
+
34
+ .toc-item-level-2 .toc-link,
35
+ .toc-item-level-3 .toc-link {
36
+ font-size: 0.95em;
37
+ }
38
+
39
+ .toc-item-level-2 .toc-link {
40
+ padding-left: 0.9rem;
41
+ }
42
+
43
+ .toc-item-level-3 .toc-link {
44
+ padding-left: 1.8rem;
45
+ font-size: 0.9em;
46
+ }
47
+
48
+ .toc-item-level-2 .toc-title,
49
+ .toc-item-level-3 .toc-title {
50
+ font-family: var(--font-body);
51
+ }
52
+
53
+ .toc-fill {
54
+ flex: 1;
55
+ border-bottom: 1px dotted rgba(0, 0, 0, 0.3);
56
+ }
57
+
58
+ .toc-page {
59
+ font-variant-numeric: tabular-nums;
60
+ min-width: 2ch;
61
+ text-align: right;
62
+ }
@@ -0,0 +1,49 @@
1
+ .book-content {
2
+ break-before: page;
3
+ }
4
+
5
+ .chapter + .chapter {
6
+ break-before: page;
7
+ }
8
+
9
+ .running-chapter-title {
10
+ margin: 0;
11
+ height: 0;
12
+ overflow: hidden;
13
+ font-size: 0;
14
+ line-height: 0;
15
+ position: running(chapter-title);
16
+ }
17
+
18
+ .chapter h1,
19
+ .chapter h2,
20
+ .chapter h3,
21
+ .chapter h4 {
22
+ font-family: var(--font-display);
23
+ line-height: 1.2;
24
+ }
25
+
26
+ .chapter h1 {
27
+ string-set: chapter-title content(text);
28
+ font-size: 1.8rem;
29
+ margin-top: 0;
30
+ margin-bottom: 1.2rem;
31
+ }
32
+
33
+ .chapter h2 {
34
+ margin-top: 1.8rem;
35
+ margin-bottom: 0.6rem;
36
+ font-size: 1.3rem;
37
+ }
38
+
39
+ .chapter p,
40
+ .chapter li {
41
+ widows: 2;
42
+ orphans: 2;
43
+ }
44
+
45
+ .chapter img {
46
+ max-width: 100%;
47
+ height: auto;
48
+ break-inside: avoid;
49
+ }
@@ -0,0 +1,48 @@
1
+ .running-header,
2
+ .running-footer {
3
+ position: fixed;
4
+ left: 0;
5
+ right: 0;
6
+ display: flex;
7
+ justify-content: space-between;
8
+ align-items: center;
9
+ font-family: var(--font-display);
10
+ font-size: 8.5pt;
11
+ letter-spacing: 0.04em;
12
+ color: var(--folio-color);
13
+ z-index: 20;
14
+ }
15
+
16
+ .running-header {
17
+ top: 6mm;
18
+ padding-left: var(--pocketbook-screen-left-margin, 16mm);
19
+ padding-right: var(--pocketbook-screen-right-margin, 16mm);
20
+ }
21
+
22
+ .running-header-chapter::after {
23
+ content: attr(data-fallback);
24
+ }
25
+
26
+ @supports (content: string(chapter-title)) {
27
+ .running-header-chapter::after {
28
+ content: string(chapter-title);
29
+ }
30
+ }
31
+
32
+ .running-footer {
33
+ bottom: 6mm;
34
+ padding-left: var(--pocketbook-screen-left-margin, 16mm);
35
+ padding-right: var(--pocketbook-screen-right-margin, 16mm);
36
+ }
37
+
38
+ .page-number::after {
39
+ content: counter(page);
40
+ }
41
+
42
+ .running-divider {
43
+ display: inline-block;
44
+ width: 16px;
45
+ height: 1px;
46
+ margin: 0 8px;
47
+ background: var(--folio-color);
48
+ }
@@ -0,0 +1,12 @@
1
+ @media print {
2
+ html,
3
+ body {
4
+ background: var(--paper);
5
+ color: var(--text-color);
6
+ }
7
+
8
+ .running-header,
9
+ .running-footer {
10
+ display: none;
11
+ }
12
+ }
@@ -0,0 +1,244 @@
1
+ require "yaml"
2
+
3
+ module Pocketbook
4
+ class Theme
5
+ class Manifest
6
+ OPTIONAL_FIELDS = %w[name version template styles defaults].freeze
7
+ SUPPORTED_FIELDS = OPTIONAL_FIELDS
8
+ DEFAULT_STYLE_NAME = "default"
9
+ SUPPORTED_DEFAULTS = %w[style title subtitle author publisher backcover_text size].freeze
10
+
11
+ class Defaults
12
+ def initialize(values, path)
13
+ @values = values
14
+ @path = path
15
+ validate!
16
+ end
17
+
18
+ def style_name
19
+ normalize_optional_string(fetch("style"))
20
+ end
21
+
22
+ def metadata
23
+ return {} if @values.nil?
24
+
25
+ @values.each_with_object({}) do |(key, value), output|
26
+ key_name = key.to_s
27
+ next if key_name == "style"
28
+
29
+ output[key_name.to_sym] = value
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def validate!
36
+ return if @values.nil?
37
+
38
+ unless @values.is_a?(Hash)
39
+ raise ArgumentError, "Manifest defaults must be a YAML object in #{@path}"
40
+ end
41
+
42
+ unknown_defaults = @values.keys.map(&:to_s) - SUPPORTED_DEFAULTS
43
+ unless unknown_defaults.empty?
44
+ raise ArgumentError,
45
+ "Unknown defaults keys in #{@path}: #{unknown_defaults.join(', ')}. Supported keys: #{SUPPORTED_DEFAULTS.join(', ')}"
46
+ end
47
+
48
+ style_default = fetch("style")
49
+ return if style_default.nil?
50
+
51
+ validate_non_empty_string!(style_default, "defaults.style")
52
+ end
53
+
54
+ def fetch(key)
55
+ return nil unless @values.is_a?(Hash)
56
+
57
+ @values[key] || @values[key.to_sym]
58
+ end
59
+
60
+ def validate_non_empty_string!(value, label)
61
+ unless value.is_a?(String) && !value.strip.empty?
62
+ raise ArgumentError, "Manifest #{label} must be a non-empty string in #{@path}"
63
+ end
64
+ end
65
+
66
+ def normalize_optional_string(value)
67
+ return nil if value.nil?
68
+
69
+ normalized = value.to_s.strip
70
+ return nil if normalized.empty?
71
+
72
+ normalized
73
+ end
74
+ end
75
+
76
+ class StyleCatalog
77
+ def initialize(styles, path)
78
+ @path = path
79
+ @style_map = normalize_style_map(styles)
80
+ end
81
+
82
+ def available_styles
83
+ @style_map.keys
84
+ end
85
+
86
+ def style?(style_name)
87
+ @style_map.key?(style_name)
88
+ end
89
+
90
+ def paths_for(style_name)
91
+ @style_map.fetch(style_name)
92
+ end
93
+
94
+ private
95
+
96
+ def normalize_style_map(styles)
97
+ return {} if styles.nil?
98
+
99
+ if styles.is_a?(String)
100
+ return { DEFAULT_STYLE_NAME => normalize_style_entries(styles, "styles") }
101
+ end
102
+
103
+ if styles.is_a?(Array)
104
+ return { DEFAULT_STYLE_NAME => normalize_style_entries(styles, "styles") }
105
+ end
106
+
107
+ unless styles.is_a?(Hash) && !styles.empty?
108
+ raise ArgumentError, "Manifest styles must be a string, a non-empty map, or a non-empty array in #{@path}"
109
+ end
110
+
111
+ styles.each_with_object({}) do |(style_name, entries), output|
112
+ style_key = style_name.to_s.strip
113
+ if style_key.empty?
114
+ raise ArgumentError, "Manifest styles map contains an empty style name in #{@path}"
115
+ end
116
+
117
+ output[style_key] = normalize_style_entries(entries, "styles.#{style_key}")
118
+ end
119
+ end
120
+
121
+ def normalize_style_entries(entries, label)
122
+ values = if entries.is_a?(String)
123
+ [entries]
124
+ elsif entries.is_a?(Array) && !entries.empty?
125
+ entries
126
+ else
127
+ raise ArgumentError, "Manifest #{label} must be a string or a non-empty array in #{@path}"
128
+ end
129
+
130
+ values.each_with_index do |value, index|
131
+ validate_non_empty_string!(value, "#{label}[#{index}]")
132
+ end
133
+
134
+ values
135
+ end
136
+
137
+ def validate_non_empty_string!(value, label)
138
+ unless value.is_a?(String) && !value.strip.empty?
139
+ raise ArgumentError, "Manifest #{label} must be a non-empty string in #{@path}"
140
+ end
141
+ end
142
+ end
143
+
144
+ attr_reader :path
145
+
146
+ def self.supported_fields
147
+ SUPPORTED_FIELDS
148
+ end
149
+
150
+ def self.supported_defaults
151
+ SUPPORTED_DEFAULTS
152
+ end
153
+
154
+ def initialize(path:)
155
+ @path = path
156
+ @values = load_manifest(path)
157
+ validate_manifest!
158
+ @defaults = Defaults.new(@values["defaults"], path)
159
+ @style_catalog = StyleCatalog.new(@values["styles"], path)
160
+ end
161
+
162
+ def name(default_name)
163
+ @values.fetch("name", default_name)
164
+ end
165
+
166
+ def version(default_version = "0.1.0")
167
+ @values.fetch("version", default_version)
168
+ end
169
+
170
+ def template_reference
171
+ normalize_optional_string(@values["template"])
172
+ end
173
+
174
+ def styles_declared?
175
+ !@style_catalog.available_styles.empty?
176
+ end
177
+
178
+ def available_styles
179
+ @style_catalog.available_styles
180
+ end
181
+
182
+ def style?(style_name)
183
+ @style_catalog.style?(style_name)
184
+ end
185
+
186
+ def style_paths_for(style_name)
187
+ @style_catalog.paths_for(style_name)
188
+ end
189
+
190
+ def default_style_name
191
+ @defaults.style_name
192
+ end
193
+
194
+ def defaults_metadata
195
+ @defaults.metadata
196
+ end
197
+
198
+ private
199
+
200
+ def load_manifest(manifest_path)
201
+ loaded = YAML.safe_load(File.read(manifest_path), aliases: true)
202
+ raise ArgumentError, "Manifest must contain a YAML object: #{manifest_path}" unless loaded.is_a?(Hash)
203
+
204
+ loaded.each_with_object({}) do |(key, value), output|
205
+ output[key.to_s] = value
206
+ end
207
+ rescue Psych::SyntaxError => e
208
+ raise ArgumentError, "Invalid theme manifest YAML in #{manifest_path}: #{e.message}"
209
+ end
210
+
211
+ def validate_manifest!
212
+ unknown_fields = @values.keys - SUPPORTED_FIELDS
213
+ unless unknown_fields.empty?
214
+ raise ArgumentError, "Unknown manifest keys in #{path}: #{unknown_fields.join(', ')}"
215
+ end
216
+
217
+ validate_optional_string!("template")
218
+ validate_optional_string!("name")
219
+ validate_optional_string!("version")
220
+ end
221
+
222
+ def validate_optional_string!(key)
223
+ return unless @values.key?(key)
224
+
225
+ validate_non_empty_string!(@values[key], key)
226
+ end
227
+
228
+ def validate_non_empty_string!(value, label)
229
+ unless value.is_a?(String) && !value.strip.empty?
230
+ raise ArgumentError, "Manifest #{label} must be a non-empty string in #{path}"
231
+ end
232
+ end
233
+
234
+ def normalize_optional_string(value)
235
+ return nil if value.nil?
236
+
237
+ normalized = value.to_s.strip
238
+ return nil if normalized.empty?
239
+
240
+ normalized
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,268 @@
1
+ require_relative "theme/manifest"
2
+
3
+ module Pocketbook
4
+ class Theme
5
+ DEFAULT_MANIFEST = "theme.yml"
6
+ DEFAULT_TEMPLATE_CANDIDATES = ["template.html.erb", "layout.html.erb"].freeze
7
+
8
+ class Location
9
+ attr_reader :root_path, :manifest_path
10
+
11
+ def initialize(theme_reference:, cwd: Dir.pwd)
12
+ input_path = Theme.resolve_theme_path(theme_reference, cwd: cwd)
13
+ @root_path = resolve_root(input_path)
14
+
15
+ raise ArgumentError, "Theme directory not found: #{theme_reference}" unless Dir.exist?(@root_path)
16
+
17
+ @manifest_path = resolve_manifest_path(input_path)
18
+ end
19
+
20
+ private
21
+
22
+ def resolve_root(input_path)
23
+ return File.dirname(input_path) if File.file?(input_path)
24
+
25
+ input_path
26
+ end
27
+
28
+ def resolve_manifest_path(input_path)
29
+ return input_path if File.file?(input_path)
30
+
31
+ theme_manifest = File.join(@root_path, DEFAULT_MANIFEST)
32
+ return theme_manifest if File.file?(theme_manifest)
33
+
34
+ nil
35
+ end
36
+ end
37
+
38
+ class AssetPaths
39
+ attr_reader :template_path, :style_paths, :style_name, :available_styles
40
+
41
+ def initialize(root_path:, manifest:, style: nil, template_override: nil)
42
+ @root_path = root_path
43
+ @manifest = manifest
44
+ @style_map = build_style_map
45
+ @available_styles = @style_map.keys.sort
46
+
47
+ @template_path = resolve_template_path(template_override)
48
+ @style_name = resolve_style_name(style)
49
+ @style_paths = resolve_style_paths(@style_name)
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_template_path(override_path)
55
+ if present?(override_path)
56
+ resolved_override = resolve_local_reference(override_path, "template")
57
+ return resolved_override if resolved_override
58
+
59
+ raise ArgumentError, "Theme template override file not found: #{override_path}"
60
+ end
61
+
62
+ manifest_template = @manifest&.template_reference
63
+ if present?(manifest_template)
64
+ resolved_manifest_template = resolve_local_reference(manifest_template, "template")
65
+ return resolved_manifest_template if resolved_manifest_template
66
+
67
+ raise ArgumentError, "Theme template file not found: #{manifest_template}"
68
+ end
69
+
70
+ DEFAULT_TEMPLATE_CANDIDATES.each do |relative_path|
71
+ candidate = File.expand_path(relative_path, @root_path)
72
+ return candidate if File.file?(candidate)
73
+ end
74
+
75
+ default_template_path
76
+ end
77
+
78
+ def resolve_local_reference(reference, label)
79
+ candidate = File.expand_path(reference, @root_path)
80
+ validate_inside_root!(candidate, label)
81
+
82
+ return candidate if File.file?(candidate)
83
+
84
+ nil
85
+ end
86
+
87
+ def default_template_path
88
+ File.join(Pocketbook.bundled_theme_path("basic"), "template.html.erb")
89
+ end
90
+
91
+ def resolve_style_name(requested_style)
92
+ explicit_style = normalize_optional_string(requested_style)
93
+ if explicit_style
94
+ return explicit_style if @style_map.key?(explicit_style)
95
+
96
+ raise ArgumentError,
97
+ "Unknown style '#{explicit_style}'. Available styles: #{available_styles.join(', ')}"
98
+ end
99
+
100
+ default_style = @manifest&.default_style_name
101
+ if default_style && @style_map.key?(default_style)
102
+ return default_style
103
+ end
104
+
105
+ return "default" if @style_map.key?("default")
106
+
107
+ available_styles.first
108
+ end
109
+
110
+ def resolve_style_paths(selected_style)
111
+ return [] if selected_style.nil?
112
+
113
+ @style_map.fetch(selected_style)
114
+ end
115
+
116
+ def build_style_map
117
+ if @manifest&.styles_declared?
118
+ return build_style_map_from_manifest
119
+ end
120
+
121
+ build_style_map_from_conventions
122
+ end
123
+
124
+ def build_style_map_from_manifest
125
+ @manifest.available_styles.each_with_object({}) do |style_name, output|
126
+ output[style_name] = @manifest.style_paths_for(style_name).map.with_index do |relative_path, index|
127
+ candidate = File.expand_path(relative_path, @root_path)
128
+
129
+ validate_inside_root!(candidate, "styles.#{style_name}[#{index}]")
130
+ unless File.extname(candidate).downcase == ".css"
131
+ raise ArgumentError, "Theme styles.#{style_name}[#{index}] must point to a .css file: #{relative_path}"
132
+ end
133
+
134
+ raise ArgumentError, "Theme style file not found: #{candidate}" unless File.file?(candidate)
135
+
136
+ candidate
137
+ end
138
+ end
139
+ end
140
+
141
+ def build_style_map_from_conventions
142
+ styles = {}
143
+
144
+ theme_css = File.expand_path("theme.css", @root_path)
145
+ styles["default"] = [theme_css] if File.file?(theme_css)
146
+
147
+ Dir.glob(File.join(@root_path, "styles", "*.css")).sort.each do |path|
148
+ style_name = File.basename(path, ".css")
149
+ styles[style_name] ||= [path]
150
+ end
151
+
152
+ Dir.glob(File.join(@root_path, "*.css")).sort.each do |path|
153
+ next if File.basename(path) == "theme.css"
154
+
155
+ style_name = File.basename(path, ".css")
156
+ styles[style_name] ||= [path]
157
+ end
158
+
159
+ styles
160
+ end
161
+
162
+ def validate_inside_root!(path, label)
163
+ expanded_root = File.expand_path(@root_path)
164
+ expanded_path = File.expand_path(path)
165
+
166
+ return if expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}/")
167
+
168
+ raise ArgumentError, "Theme #{label} path escapes theme root: #{path}"
169
+ end
170
+
171
+ def present?(value)
172
+ !value.nil? && !value.to_s.strip.empty?
173
+ end
174
+
175
+ def normalize_optional_string(value)
176
+ return nil if value.nil?
177
+
178
+ normalized = value.to_s.strip
179
+ return nil if normalized.empty?
180
+
181
+ normalized
182
+ end
183
+ end
184
+
185
+ attr_reader :root_path, :template_path, :style_paths, :style_name, :available_styles
186
+
187
+ def self.supported_manifest_fields
188
+ Manifest.supported_fields
189
+ end
190
+
191
+ def self.supported_manifest_defaults
192
+ Manifest.supported_defaults
193
+ end
194
+
195
+ def self.resolve_theme_path(theme_reference, cwd: Dir.pwd)
196
+ reference = theme_reference.to_s.strip
197
+ return File.expand_path(reference, cwd) if path_reference?(reference)
198
+
199
+ project_theme_path = File.expand_path(File.join("themes", reference), cwd)
200
+ return project_theme_path if theme_location_exists?(project_theme_path)
201
+
202
+ user_theme_path = Pocketbook.user_themes_path(reference)
203
+ return user_theme_path if theme_location_exists?(user_theme_path)
204
+
205
+ bundled_path = Pocketbook.bundled_theme_path(reference)
206
+ return bundled_path if theme_location_exists?(bundled_path)
207
+
208
+ File.expand_path(reference, cwd)
209
+ end
210
+
211
+ def initialize(theme_path:, style: nil, template_override: nil, cwd: Dir.pwd)
212
+ location = Location.new(theme_reference: theme_path, cwd: cwd)
213
+ @root_path = location.root_path
214
+ @manifest = location.manifest_path ? Manifest.new(path: location.manifest_path) : nil
215
+
216
+ assets = AssetPaths.new(
217
+ root_path: @root_path,
218
+ manifest: @manifest,
219
+ style: style,
220
+ template_override: template_override
221
+ )
222
+ @template_path = assets.template_path
223
+ @style_name = assets.style_name
224
+ @style_paths = assets.style_paths
225
+ @available_styles = assets.available_styles
226
+ end
227
+
228
+ def name
229
+ default_name = File.basename(root_path)
230
+ return default_name unless @manifest
231
+
232
+ @manifest.name(default_name)
233
+ end
234
+
235
+ def version
236
+ return "0.1.0" unless @manifest
237
+
238
+ @manifest.version
239
+ end
240
+
241
+ def defaults_metadata
242
+ return {} unless @manifest
243
+
244
+ @manifest.defaults_metadata
245
+ end
246
+
247
+ def template_source
248
+ File.read(template_path)
249
+ end
250
+
251
+ def styles_css
252
+ style_paths.map { |path| File.read(path) }.join("\n\n")
253
+ end
254
+
255
+ private
256
+
257
+ def self.path_reference?(value)
258
+ return true if value.start_with?("~", ".", "/")
259
+
260
+ separators = [File::SEPARATOR, File::ALT_SEPARATOR].compact.uniq
261
+ separators.any? { |separator| value.include?(separator) }
262
+ end
263
+
264
+ def self.theme_location_exists?(path)
265
+ File.directory?(path) || File.file?(path)
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,3 @@
1
+ module Pocketbook
2
+ VERSION = "0.1.0"
3
+ end