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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 37b9f2a71ec28291568640b6d39517ceea652a88bd482ab777031cd37ad454f1
|
|
4
|
+
data.tar.gz: abf41efcbdb373f4cef42f1d412688fd8fe2f8b8d2a3648ae7791135ad12b152
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a2e3236ae901546cc3ba5d78fae971ad157eca451347f1281a7266fd94ae9feb05bd020cdc3f08b3c36cdc8e6c4d58dd3f883af7b1a95f38b34b753e360fcbf
|
|
7
|
+
data.tar.gz: 5bbc0dd0f9f9005db089d0da98f73ec55e1b2bcb1c73eff8345d8a3d55db63afb3d3029346b064a36aeb7ae8264b744bad77ab30dd9048220a730f6cb12c9e62
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pocketbook contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="pocketbook.png" alt="Pocketbook Logo" width="200">
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
# Pocketbook
|
|
6
|
+
|
|
7
|
+
Turn markdown into easily shareable, print-ready PDFs.
|
|
8
|
+
|
|
9
|
+
Pocketbook is an alpha Ruby project for generating book-style PDFs from markdown using a theme template and print CSS. It is built for:
|
|
10
|
+
|
|
11
|
+
- authors and self-publishers who write in markdown
|
|
12
|
+
- developers who need repeatable markdown-to-PDF output
|
|
13
|
+
- designers and publishers who want layout control via reusable themes
|
|
14
|
+
|
|
15
|
+
## Why Pocketbook
|
|
16
|
+
|
|
17
|
+
- **Markdown-first**: Keep content in plain markdown files. Use one file or many.
|
|
18
|
+
- **Print-first**: Render with CSS `@page`, named pages, running elements, and mirrored margins.
|
|
19
|
+
- **Theme-driven**: Package template and style variants together for consistent output.
|
|
20
|
+
- **Shareable output**: Produce a polished PDF that is easy to distribute.
|
|
21
|
+
- **Testable rendering**: Validate layout stability with fixture-driven visual regression tests.
|
|
22
|
+
|
|
23
|
+
## Start In 60 Seconds
|
|
24
|
+
|
|
25
|
+
### 1. Install the gem
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gem install pocketbook
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Generate your first PDF
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pocketbook build \
|
|
35
|
+
examples/sample.md \
|
|
36
|
+
--theme classic \
|
|
37
|
+
--style light \
|
|
38
|
+
--size A4 \
|
|
39
|
+
--output output/book-a4.pdf
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For a plain document without cover, TOC, or back cover sections, use the basic theme:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pocketbook build \
|
|
46
|
+
examples/sample.md \
|
|
47
|
+
--theme themes/basic \
|
|
48
|
+
--style plain \
|
|
49
|
+
--size A4 \
|
|
50
|
+
--output output/document-a4.pdf
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`classic` is the default bundled theme. Use `--theme themes/basic` when you want a plain content-only layout.
|
|
54
|
+
|
|
55
|
+
When `--theme` receives a bare name such as `classic`, Pocketbook first looks for a bundled theme with that name. If none exists, it falls back to resolving the value from the current working directory. Any explicit path such as `themes/classic`, `./my-theme`, or `/abs/path/to/theme.yml` is resolved directly as a path.
|
|
56
|
+
|
|
57
|
+
### 4. Open the result
|
|
58
|
+
|
|
59
|
+
Your PDF is ready at `output/book-a4.pdf`.
|
|
60
|
+
|
|
61
|
+
To automatically open the generated PDF in your default viewer:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pocketbook build examples/sample.md --open
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Live Rebuild While Editing
|
|
68
|
+
|
|
69
|
+
Use the dedicated `watch` command to rebuild the PDF when relevant files change.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pocketbook watch \
|
|
73
|
+
examples/sample.md \
|
|
74
|
+
--theme themes/classic \
|
|
75
|
+
--style light \
|
|
76
|
+
--size A4 \
|
|
77
|
+
--output output/book-a4.pdf
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
By default, changes are debounced by 350ms. You can tune it:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pocketbook watch \
|
|
84
|
+
examples/sample.md \
|
|
85
|
+
--theme themes/classic \
|
|
86
|
+
--output output/book-a4.pdf \
|
|
87
|
+
--debounce-ms 500
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
After each successful rebuild, Pocketbook prints:
|
|
91
|
+
|
|
92
|
+
- a `file://...` PDF link
|
|
93
|
+
- an `xdg-open "..."` command you can run directly
|
|
94
|
+
|
|
95
|
+
## What The Generated PDF Includes
|
|
96
|
+
|
|
97
|
+
- cover page
|
|
98
|
+
- generated table of contents
|
|
99
|
+
- body pages with mirrored margins
|
|
100
|
+
- running header, footer, and page marker
|
|
101
|
+
- backcover page
|
|
102
|
+
|
|
103
|
+
## Build From Multiple Markdown Files
|
|
104
|
+
|
|
105
|
+
Pass files positionally to concatenate them in order:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pocketbook build \
|
|
109
|
+
chapters/01-intro.md \
|
|
110
|
+
chapters/02-method.md \
|
|
111
|
+
chapters/03-results.md \
|
|
112
|
+
--theme themes/classic \
|
|
113
|
+
--style light \
|
|
114
|
+
--size A4 \
|
|
115
|
+
--output output/book-multi-input.pdf
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
Runtime requirements:
|
|
121
|
+
|
|
122
|
+
- Ruby 3.x
|
|
123
|
+
- local Chrome or Chromium (used by `ferrum`)
|
|
124
|
+
|
|
125
|
+
Development/test-only dependencies:
|
|
126
|
+
|
|
127
|
+
- ImageMagick (`compare`) for visual diff metric checks in `bin/test-render`
|
|
128
|
+
- Poppler tools (`pdftoppm`, `pdfinfo`, `pdftotext`) for page rasterization and text/page-count assertions
|
|
129
|
+
|
|
130
|
+
Example package names:
|
|
131
|
+
|
|
132
|
+
- Debian/Ubuntu: `imagemagick poppler-utils chromium`
|
|
133
|
+
- macOS (Homebrew): `imagemagick poppler`
|
|
134
|
+
|
|
135
|
+
## Theme Workflow (Progressive)
|
|
136
|
+
|
|
137
|
+
Pocketbook supports progressive themeing. Start simple, then add structure only if needed.
|
|
138
|
+
|
|
139
|
+
### Level 0: no custom theme files
|
|
140
|
+
|
|
141
|
+
Use the bundled default theme:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pocketbook build book.md --output output/book.pdf
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Level 1: CSS-only theme (recommended first step)
|
|
148
|
+
|
|
149
|
+
Create a folder with `theme.css`:
|
|
150
|
+
|
|
151
|
+
```txt
|
|
152
|
+
themes/
|
|
153
|
+
clean/
|
|
154
|
+
theme.css
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Then build with it:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pocketbook build book.md --theme themes/clean --output output/book.pdf
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Level 2: Optional custom template
|
|
164
|
+
|
|
165
|
+
Add `template.html.erb` (or `layout.html.erb`) to the theme directory:
|
|
166
|
+
|
|
167
|
+
```txt
|
|
168
|
+
themes/
|
|
169
|
+
clean/
|
|
170
|
+
theme.css
|
|
171
|
+
template.html.erb
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Optional metadata manifest (`theme.yml`)
|
|
175
|
+
|
|
176
|
+
`theme.yml` is optional. Use it only when you need explicit style maps, template path overrides, or defaults.
|
|
177
|
+
|
|
178
|
+
Supported top-level keys:
|
|
179
|
+
|
|
180
|
+
- `name`
|
|
181
|
+
- `version`
|
|
182
|
+
- `template`
|
|
183
|
+
- `styles`
|
|
184
|
+
- `defaults`
|
|
185
|
+
|
|
186
|
+
Supported `defaults` keys:
|
|
187
|
+
|
|
188
|
+
- `style`
|
|
189
|
+
- `title`
|
|
190
|
+
- `subtitle`
|
|
191
|
+
- `author`
|
|
192
|
+
- `publisher`
|
|
193
|
+
- `backcover_text`
|
|
194
|
+
- `size`
|
|
195
|
+
|
|
196
|
+
Example `theme.yml`:
|
|
197
|
+
|
|
198
|
+
```yaml
|
|
199
|
+
name: classic
|
|
200
|
+
version: 0.2.0
|
|
201
|
+
template: template.html.erb
|
|
202
|
+
styles:
|
|
203
|
+
light:
|
|
204
|
+
- styles/base.css
|
|
205
|
+
- styles/light.css
|
|
206
|
+
dark:
|
|
207
|
+
- styles/base.css
|
|
208
|
+
- styles/dark.css
|
|
209
|
+
defaults:
|
|
210
|
+
style: light
|
|
211
|
+
publisher: Pocketbook Labs
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Style selection precedence:
|
|
215
|
+
|
|
216
|
+
1. `--style` CLI flag
|
|
217
|
+
2. `defaults.style` in `theme.yml` (if manifest exists)
|
|
218
|
+
3. `default` style when discoverable (for example `theme.css`)
|
|
219
|
+
4. first discovered style
|
|
220
|
+
|
|
221
|
+
## Front Matter
|
|
222
|
+
|
|
223
|
+
The first markdown input file can include YAML front matter.
|
|
224
|
+
|
|
225
|
+
Supported keys:
|
|
226
|
+
|
|
227
|
+
- `title`
|
|
228
|
+
- `subtitle`
|
|
229
|
+
- `author`
|
|
230
|
+
- `publisher`
|
|
231
|
+
- `backcover_text`
|
|
232
|
+
- `size`
|
|
233
|
+
|
|
234
|
+
Metadata precedence:
|
|
235
|
+
|
|
236
|
+
1. CLI flags
|
|
237
|
+
2. markdown front matter
|
|
238
|
+
3. theme defaults
|
|
239
|
+
4. renderer defaults
|
|
240
|
+
|
|
241
|
+
## Visual Regression Tests
|
|
242
|
+
|
|
243
|
+
Pocketbook ships with fixture-driven visual regression tests using `minitest`.
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Check current output against expected fixtures
|
|
247
|
+
bin/test-render check
|
|
248
|
+
|
|
249
|
+
# Rebuild expected fixtures from current renderer output
|
|
250
|
+
bin/test-render update
|
|
251
|
+
|
|
252
|
+
# Print configured fixture case IDs
|
|
253
|
+
bin/test-render list
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Fixture layout:
|
|
257
|
+
|
|
258
|
+
- `test/fixtures/inputs/`: markdown fixtures
|
|
259
|
+
- `test/fixtures/expected/<case-id>/pages/`: expected PNG pages
|
|
260
|
+
- `test/fixtures/cases.yml`: case manifest
|
|
261
|
+
|
|
262
|
+
## CLI
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
Usage: pocketbook build FILE.md [MORE.md ...] [options]
|
|
266
|
+
|
|
267
|
+
Main commands:
|
|
268
|
+
build Build a PDF (default command)
|
|
269
|
+
watch Rebuild PDF on file changes
|
|
270
|
+
theme new NAME Scaffold a new theme directory
|
|
271
|
+
theme validate [THEME] Validate that a theme resolves correctly
|
|
272
|
+
theme inspect [THEME] Print resolved theme details
|
|
273
|
+
theme get URL Download a theme from a GitHub theme.yml URL
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Theme command examples:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
# Themes are stored globally by default in ~/.pocketbook/themes
|
|
280
|
+
pocketbook theme new clean
|
|
281
|
+
pocketbook theme new clean --with-template
|
|
282
|
+
pocketbook theme validate clean
|
|
283
|
+
pocketbook theme inspect classic --style dark
|
|
284
|
+
pocketbook theme get https://github.com/org/repo/blob/main/themes/minimal/theme.yml
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Build diagnostics example:
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
pocketbook build examples/sample.md --diagnostics --open
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Contributing
|
|
294
|
+
|
|
295
|
+
Contributions are welcome.
|
|
296
|
+
|
|
297
|
+
Start with [CONTRIBUTING.md](CONTRIBUTING.md) for setup, validation commands, and pull request expectations.
|
|
298
|
+
|
|
299
|
+
Quick path:
|
|
300
|
+
|
|
301
|
+
1. Fork the repo and create a feature branch.
|
|
302
|
+
2. Run `bundle install`.
|
|
303
|
+
3. Make your changes and run `bin/test-render check`.
|
|
304
|
+
4. Open a pull request with a short summary and rationale.
|
|
305
|
+
|
|
306
|
+
## License
|
|
307
|
+
|
|
308
|
+
This project is licensed under the MIT License. See [LICENSE](LICENSE).
|
data/bin/pocketbook
ADDED
data/bin/test-render
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
ROOT = File.expand_path("..", __dir__)
|
|
7
|
+
FIXTURE_CASES = File.join(ROOT, "test", "fixtures", "cases.yml")
|
|
8
|
+
|
|
9
|
+
def usage
|
|
10
|
+
<<~TEXT
|
|
11
|
+
Usage: bin/test-render [check|update|list]
|
|
12
|
+
|
|
13
|
+
check Run fixture-driven visual regression tests (default)
|
|
14
|
+
update Rebuild expected PNG fixtures from current renderer output
|
|
15
|
+
list Print configured fixture case IDs
|
|
16
|
+
TEXT
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def list_cases
|
|
20
|
+
data = YAML.safe_load(File.read(FIXTURE_CASES), aliases: true) || {}
|
|
21
|
+
cases = data.fetch("cases", [])
|
|
22
|
+
abort("No fixture cases configured in #{FIXTURE_CASES}") if cases.empty?
|
|
23
|
+
|
|
24
|
+
cases.each { |entry| puts entry.fetch("id") }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
command = ARGV[0] || "check"
|
|
28
|
+
|
|
29
|
+
Dir.chdir(ROOT)
|
|
30
|
+
|
|
31
|
+
case command
|
|
32
|
+
when "check"
|
|
33
|
+
env = {}
|
|
34
|
+
when "update"
|
|
35
|
+
env = { "POCKETBOOK_UPDATE_FIXTURES" => "1" }
|
|
36
|
+
when "list"
|
|
37
|
+
list_cases
|
|
38
|
+
exit(0)
|
|
39
|
+
when "-h", "--help", "help"
|
|
40
|
+
puts usage
|
|
41
|
+
exit(0)
|
|
42
|
+
else
|
|
43
|
+
warn "Unknown command: #{command}"
|
|
44
|
+
warn usage
|
|
45
|
+
exit(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
success = system(env, "bundle", "exec", "ruby", "-Itest", "test/visual_regression_test.rb")
|
|
49
|
+
exit(success ? 0 : 1)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Pocketbook
|
|
2
|
+
class Book
|
|
3
|
+
attr_reader :inputs, :theme, :metadata
|
|
4
|
+
|
|
5
|
+
def initialize(inputs:, theme:, metadata: {})
|
|
6
|
+
@inputs = inputs
|
|
7
|
+
@theme = theme
|
|
8
|
+
@metadata = metadata
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render_to(output_path)
|
|
12
|
+
BookRenderer.new(
|
|
13
|
+
inputs: @inputs,
|
|
14
|
+
theme: @theme,
|
|
15
|
+
output_path: output_path,
|
|
16
|
+
metadata: @metadata
|
|
17
|
+
).render
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render_html
|
|
21
|
+
BookRenderer.new(
|
|
22
|
+
inputs: @inputs,
|
|
23
|
+
theme: @theme,
|
|
24
|
+
metadata: @metadata
|
|
25
|
+
).render_html
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "kramdown"
|
|
3
|
+
|
|
4
|
+
module Pocketbook
|
|
5
|
+
class BookRenderer
|
|
6
|
+
class Chapter
|
|
7
|
+
TocHeading = Struct.new(:id, :title, :level, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
Compiled = Struct.new(:id, :source, :title, :html, :toc_headings, keyword_init: true) do
|
|
10
|
+
def article_html(escape_html:)
|
|
11
|
+
<<~HTML
|
|
12
|
+
<article class="chapter" id="#{escape_html.call(id)}" data-source="#{escape_html.call(source)}" data-title="#{escape_html.call(title)}">
|
|
13
|
+
<p class="running-chapter-title" aria-hidden="true">#{escape_html.call(title)}</p>
|
|
14
|
+
#{html}
|
|
15
|
+
</article>
|
|
16
|
+
HTML
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(front_matter: FrontMatter.new)
|
|
21
|
+
@front_matter = front_matter
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def compile(inputs:)
|
|
25
|
+
front_matter = {}
|
|
26
|
+
|
|
27
|
+
chapters = inputs.map.with_index do |path, index|
|
|
28
|
+
raw = File.read(path)
|
|
29
|
+
|
|
30
|
+
if index.zero?
|
|
31
|
+
front_matter, raw = @front_matter.extract(raw)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
title = detect_title(raw, path)
|
|
35
|
+
chapter_html = Kramdown::Document.new(raw, input: "GFM").to_html
|
|
36
|
+
|
|
37
|
+
Compiled.new(
|
|
38
|
+
id: chapter_id(title, index),
|
|
39
|
+
source: path,
|
|
40
|
+
title: title,
|
|
41
|
+
html: chapter_html,
|
|
42
|
+
toc_headings: extract_toc_headings(chapter_html)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
[chapters, front_matter]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def chapter_id(title, index)
|
|
52
|
+
slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
53
|
+
slug = "chapter" if slug.empty?
|
|
54
|
+
|
|
55
|
+
"chapter-#{index + 1}-#{slug}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def detect_title(markdown, path)
|
|
59
|
+
markdown.each_line do |line|
|
|
60
|
+
if (match = line.match(/\A#\s+(.+)\s*$/))
|
|
61
|
+
return match[1].strip
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
File.basename(path, File.extname(path)).tr("_", " ")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def extract_toc_headings(chapter_html)
|
|
69
|
+
chapter_html.scan(/<h([1-3])([^>]*)>(.*?)<\/h\1>/mi).filter_map do |level, attributes, inner_html|
|
|
70
|
+
id_match = attributes.match(/\bid=(['"])(.*?)\1/i)
|
|
71
|
+
next if id_match.nil?
|
|
72
|
+
|
|
73
|
+
text = CGI.unescapeHTML(inner_html.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip)
|
|
74
|
+
next if text.empty?
|
|
75
|
+
|
|
76
|
+
TocHeading.new(
|
|
77
|
+
id: id_match[2],
|
|
78
|
+
title: text,
|
|
79
|
+
level: level.to_i
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Pocketbook
|
|
4
|
+
class BookRenderer
|
|
5
|
+
class FrontMatter
|
|
6
|
+
def extract(markdown)
|
|
7
|
+
match = markdown.match(/\A---\s*\n(.*?)\n---\s*\n/m)
|
|
8
|
+
return [{}, markdown] unless match
|
|
9
|
+
|
|
10
|
+
data = YAML.safe_load(match[1], aliases: true)
|
|
11
|
+
data = {} unless data.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
[symbolize_keys(data), markdown[match[0].length..]]
|
|
14
|
+
rescue Psych::SyntaxError
|
|
15
|
+
[{}, markdown]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def symbolize_keys(hash)
|
|
21
|
+
hash.each_with_object({}) do |(key, value), output|
|
|
22
|
+
output[key.to_sym] = value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Pocketbook
|
|
2
|
+
class BookRenderer
|
|
3
|
+
class Metadata
|
|
4
|
+
DEFAULT_VALUES = {
|
|
5
|
+
title: "Untitled Book",
|
|
6
|
+
subtitle: "",
|
|
7
|
+
author: "Unknown Author",
|
|
8
|
+
publisher: "Pocketbook",
|
|
9
|
+
backcover_text: "",
|
|
10
|
+
size: "6in 9in"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
METADATA_KEYS = %i[title subtitle author publisher backcover_text size].freeze
|
|
14
|
+
|
|
15
|
+
def resolve(theme_defaults:, cli_metadata:, front_matter:, first_input_path:)
|
|
16
|
+
metadata = DEFAULT_VALUES
|
|
17
|
+
.merge(theme_defaults)
|
|
18
|
+
.merge(cli_metadata)
|
|
19
|
+
|
|
20
|
+
metadata[:size] = normalize_size(metadata[:size])
|
|
21
|
+
apply_front_matter!(metadata, front_matter, cli_metadata)
|
|
22
|
+
apply_input_fallbacks!(metadata, first_input_path)
|
|
23
|
+
|
|
24
|
+
metadata
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def apply_front_matter!(metadata, front_matter, cli_metadata)
|
|
30
|
+
return if front_matter.empty?
|
|
31
|
+
|
|
32
|
+
METADATA_KEYS.each do |key|
|
|
33
|
+
next unless front_matter.key?(key)
|
|
34
|
+
next if blank?(front_matter[key])
|
|
35
|
+
next if cli_metadata.key?(key) && !blank?(cli_metadata[key])
|
|
36
|
+
|
|
37
|
+
metadata[key] = front_matter[key].to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_input_fallbacks!(metadata, first_input_path)
|
|
42
|
+
first_input_name = File.basename(first_input_path, File.extname(first_input_path)).tr("_", " ")
|
|
43
|
+
|
|
44
|
+
if metadata[:title] == DEFAULT_VALUES[:title]
|
|
45
|
+
metadata[:title] = first_input_name.split.map(&:capitalize).join(" ")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if blank?(metadata[:backcover_text])
|
|
49
|
+
metadata[:backcover_text] = "A pocketbook generated from markdown."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
metadata[:size] = normalize_size(metadata[:size])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_size(value)
|
|
56
|
+
raw = value.to_s.strip
|
|
57
|
+
return DEFAULT_VALUES[:size] if raw.empty?
|
|
58
|
+
|
|
59
|
+
match = raw.match(/\A(\d+(?:\.\d+)?(?:in|mm|cm|pt|pc|px))\s*[xX]\s*(\d+(?:\.\d+)?(?:in|mm|cm|pt|pc|px))\z/)
|
|
60
|
+
return "#{match[1]} #{match[2]}" if match
|
|
61
|
+
|
|
62
|
+
raw
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def blank?(value)
|
|
66
|
+
value.nil? || value.to_s.strip.empty?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "tmpdir"
|
|
2
|
+
require "ferrum"
|
|
3
|
+
require_relative "../pdf_document"
|
|
4
|
+
|
|
5
|
+
module Pocketbook
|
|
6
|
+
class BookRenderer
|
|
7
|
+
class Pdf
|
|
8
|
+
def write(html:, output_path:)
|
|
9
|
+
Dir.mktmpdir("pocketbook") do |dir|
|
|
10
|
+
html_path = File.join(dir, "book.html")
|
|
11
|
+
File.write(html_path, html)
|
|
12
|
+
|
|
13
|
+
browser = Ferrum::Browser.new(timeout: 30)
|
|
14
|
+
page = browser.create_page
|
|
15
|
+
page.go_to("file://#{html_path}")
|
|
16
|
+
page.network.wait_for_idle
|
|
17
|
+
page.pdf(
|
|
18
|
+
path: output_path,
|
|
19
|
+
print_background: true,
|
|
20
|
+
prefer_css_page_size: true
|
|
21
|
+
)
|
|
22
|
+
ensure
|
|
23
|
+
browser&.quit
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def toc_page_numbers(pdf_path:, toc_targets:)
|
|
28
|
+
return {} if toc_targets.empty?
|
|
29
|
+
|
|
30
|
+
pdf_document_for(pdf_path).toc_page_numbers(toc_targets)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def pdf_document_for(pdf_path)
|
|
38
|
+
Pocketbook::PdfDocument.open(pdf_path)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Pocketbook
|
|
2
|
+
class BookRenderer
|
|
3
|
+
class Toc
|
|
4
|
+
def build(chapters:, escape_html:, page_numbers: {})
|
|
5
|
+
toc_entries(chapters).map do |entry|
|
|
6
|
+
toc_item_html(
|
|
7
|
+
id: entry[:id],
|
|
8
|
+
title: entry[:title],
|
|
9
|
+
level: entry[:level],
|
|
10
|
+
page_number: page_numbers[entry[:id]],
|
|
11
|
+
escape_html: escape_html
|
|
12
|
+
)
|
|
13
|
+
end.join("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def targets(chapters:)
|
|
17
|
+
toc_entries(chapters).map { |entry| entry[:id] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def toc_entries(chapters)
|
|
23
|
+
entries = []
|
|
24
|
+
|
|
25
|
+
chapters.each do |chapter|
|
|
26
|
+
headings = chapter.toc_headings || []
|
|
27
|
+
if headings.empty?
|
|
28
|
+
entries << { id: chapter.id, title: chapter.title, level: 1 }
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
headings.each do |heading|
|
|
33
|
+
entries << { id: heading.id, title: heading.title, level: heading.level }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
entries
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def toc_item_html(id:, title:, level:, page_number:, escape_html:)
|
|
41
|
+
page_label = page_number.nil? ? "" : escape_html.call(page_number)
|
|
42
|
+
|
|
43
|
+
<<~HTML
|
|
44
|
+
<li class="toc-item toc-item-level-#{level}">
|
|
45
|
+
<a class="toc-link" href="##{escape_html.call(id)}">
|
|
46
|
+
<span class="toc-title">#{escape_html.call(title)}</span>
|
|
47
|
+
<span class="toc-fill" aria-hidden="true"></span>
|
|
48
|
+
<span class="toc-page" aria-label="Page">#{page_label}</span>
|
|
49
|
+
</a>
|
|
50
|
+
</li>
|
|
51
|
+
HTML
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|