booksmith 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 38b21a8ba67192f56f7796f1eb76cffe35515449ae9ca7c3a53f20b1aea1d710
4
+ data.tar.gz: 017caa797d013fcd07cf3d0bbd54501a0e23fd5ed7fa273b7ebb91a19ba54474
5
+ SHA512:
6
+ metadata.gz: e00830e76a4e9e30679a1681c5f8b82063bd075d5e86c1bf3a4db398f6ef38249b511bdbee279292303379141a9fee03ab4278b5c1da7380cca73fed61ad5b4d
7
+ data.tar.gz: 8f324bc51843471ade117b2eeb21a9d8453d94b0c658f655a1f7aea64e441363a82e00422bf405d135e9dd21fc5a9eabdc922566e5b067c6f47f5756d347bdd7
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonid Medovyy
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,306 @@
1
+ # Booksmith
2
+
3
+ [![Gem Version](https://img.shields.io/badge/gem-v1.0.0-crimson.svg?style=flat-square)](https://rubygems.org/gems/booksmith)
4
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.0-ruby.svg?style=flat-square)](https://www.ruby-lang.org)
5
+ [![Amazon KDP](https://img.shields.io/badge/Amazon%20KDP-Print%20%26%20eBook%20Ready-orange.svg?style=flat-square)](https://kdp.amazon.com)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE.txt)
7
+
8
+ > **The production-grade, developer-first publishing engine that transforms Markdown chapters into store-ready Paperback, Hardcover, and Kindle eBook (EPUB) editions.**
9
+
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ Self-publishing technical guides, fiction, or non-fiction on **Amazon Kindle Direct Publishing (KDP)** usually requires painful manual formatting in Word/InDesign or fragile HTML-to-PDF scripts that fail Amazon's automated interior print inspection.
15
+
16
+ **Booksmith** solves this with a purpose-built, dual-engine compilation pipeline. Written in Ruby, Booksmith reads clean Markdown and compiles it into:
17
+
18
+ - **Print-Ready PDFs** configured with Amazon KDP-compliant margins, geometry, vector typography, running headers, and dynamically resolved tables of contents.
19
+ - **Reflowable EPUB 3 eBooks** bundled with embedded typography, validated navigation manifests, and custom callout styling ready for Kindle ingestion.
20
+
21
+ ---
22
+
23
+ ## 🏆 Battle-Tested in Production
24
+
25
+ Booksmith isn't a theoretical proof-of-concept—it powers live, commercial titles available on Amazon worldwide across **Paperback**, **Hardcover**, and **Kindle eBook** formats:
26
+
27
+ | Title | Formats Published | Store Link |
28
+ | :--- | :--- | :--- |
29
+ | **Modern Applications of Ninjutsu** | Hardcover • Kindle | [View on Amazon](https://www.amazon.com/dp/B0HNF1T788/) |
30
+ | **Jealousy in the Jungle: Invitation & Rival's Awakening** | Hardcover • Kindle | [View on Amazon](https://www.amazon.com/Jealousy-Jungle-Invitation-Rivals-Awakening/dp/B0DY872FHM/) |
31
+
32
+ > [!NOTE]
33
+ > Both titles passed Amazon KDP's automated preflight review, interior gutter/margin validation, and reflowable eBook ingest with **zero formatting rejections**.
34
+
35
+ ---
36
+
37
+ ## ✨ Key Features
38
+
39
+ - **Dual-Target Compilation**: Generate both print-ready PDF and Kindle-compliant EPUB simultaneously with a single CLI command (`booksmith build --format all`).
40
+ - **Two-Pass Dynamic Pagination**: Calculates exact chapter starting pages and table-of-contents page offsets automatically prior to final PDF generation.
41
+ - **Self-Contained Typography**: Bundles Google's **Noto Sans** and **Noto Sans Mono** TrueType font families. No external font dependencies or OS-level font installations required.
42
+ - **Rich Callout Blocks**:
43
+ - **Standard Callouts**: Automatically styles bold headers into shaded callout blocks.
44
+ - **Alternate Callouts (`{: .alt_blockquote}`)**: Tinted background containers for sidebars and tips.
45
+ - **Exercise Callouts (`{: .exercise_blockquote}`)**: Callout cards with customizable asset icons (e.g., timer/clock) and point-level vertical alignment nudges.
46
+ - **Print Layout Control**:
47
+ - `<!-- subtitle: ... -->` for clean chapter metadata without breaking Markdown linters.
48
+ - `<!-- PAGE_BREAK -->` comments for manual page control across physical print layouts.
49
+ - **Automated Image Pipeline**: Parses inline Markdown sizing attributes (`"width=350 top=10 bottom=10"`) and rasterizes transparent PNG backgrounds via ImageMagick to prevent black-box PDF rendering artifacts.
50
+
51
+ ---
52
+
53
+ ## 🏗️ Architecture & Engineering Highlights
54
+
55
+ Booksmith was engineered with a modular, extensible architecture that separates AST parsing, layout geometry calculations, and format-specific document packaging.
56
+
57
+ ```mermaid
58
+ flowchart TD
59
+ MD["Markdown Chapters (chapters/*.md)"] --> AST["Kramdown AST Parser & Normalizer"]
60
+ CFG["Configuration (config/book.yml)"] --> AST
61
+
62
+ subgraph PDF_Pipeline ["Print Engine: Prawn (2-Pass Layout)"]
63
+ AST --> P1["Pass 1: Render Dry-Run & Measure Offsets"]
64
+ P1 --> TOC["Compute TOC Height & Page Shift Offset"]
65
+ TOC --> P2["Pass 2: Final Vector Rendering & Running Pagination"]
66
+ P2 --> PDF_OUT["book.pdf (Paperback & Hardcover Ready)"]
67
+ end
68
+
69
+ subgraph EPUB_Pipeline ["eBook Engine: GEPUB (EPUB 3 / XHTML)"]
70
+ AST --> XGEN["XHTML 1.1 Document Generator & CSS Styler"]
71
+ XGEN --> SPN["Embed Fonts, Assets & Build EPUB Spine"]
72
+ SPN --> EPUB_OUT["book.epub (Kindle KDP Ready)"]
73
+ end
74
+ ```
75
+
76
+ ### Engineering Design Highlights
77
+
78
+ #### 1. Two-Pass PDF Pagination & TOC Resolution
79
+
80
+ Generating a table of contents in print documents presents a circular dependency: adding TOC entries can push chapter pages down, altering the page numbers that the TOC itself displays. Booksmith resolves this with a deterministic two-pass algorithm:
81
+
82
+ 1. **Pass 1 (Measurement)**: Traverses the Kramdown AST in an in-memory Prawn buffer to compute exact chapter heights, section boundaries, and TOC page space.
83
+ 2. **Pass 2 (Synthesis)**: Applies the computed TOC page offset, injects the generated Table of Contents, formats running page numbers (`page_content_bottom_gap`), and writes the final vector PDF.
84
+
85
+ #### 2. Vector Typography & Geometry Engine
86
+
87
+ Instead of relying on web-view wrappers or headless browsers, Booksmith renders directly to PDF primitives using `Prawn`. Text leading, bullet indentations (`list_bullet_indent`, `list_marker_gap`), and callout paddings are calculated in points, producing crisp vector type that meets high-resolution commercial print standards (300+ DPI).
88
+
89
+ #### 3. Dual-Format AST Translation
90
+
91
+ Booksmith normalizes Markdown elements into an intermediate AST via Kramdown, then branches execution:
92
+
93
+ - **PDF Converter** (`Booksmith::PDFConverter`): Translates elements into low-level Prawn cursor calls, handling bounding boxes, font switching, and multi-column icon callouts.
94
+ - **EPUB Generator** (`Booksmith::EpubGenerator`): Serializes nodes into strict XHTML, injects embedded CSS, registers the TrueType fonts in the OPF manifest, and packages the complete EPUB 3 archive via GEPUB.
95
+
96
+ #### 4. Automated Image Pre-processing & Alpha Flattening
97
+
98
+ PDF renderers and commercial print presses frequently misrender PNG alpha transparency as opaque black blocks. Booksmith reads configuration-driven background colors (`image_flatten_bg_color`) and utilizes ImageMagick to composite transparent images against the target page tone before embedding.
99
+
100
+ ---
101
+
102
+ ## 🚀 Quickstart
103
+
104
+ ### 1. Installation
105
+
106
+ Install Booksmith via RubyGems:
107
+
108
+ ```sh
109
+ gem install booksmith
110
+ ```
111
+
112
+ Or add it to your project's `Gemfile`:
113
+
114
+ ```ruby
115
+ gem 'booksmith', '~> 1.0'
116
+ ```
117
+
118
+ ### 2. Initialize a Project
119
+
120
+ Create a new book directory and initialize the scaffolding:
121
+
122
+ ```sh
123
+ mkdir my-masterpiece
124
+ cd my-masterpiece
125
+ booksmith init
126
+ ```
127
+
128
+ This generates the standard project structure:
129
+
130
+ ```
131
+ my-masterpiece/
132
+ ├── assets/
133
+ │ └── clock.png # Sample exercise callout icon
134
+ ├── chapters/
135
+ │ └── 01_introduction.md # Starter chapter template
136
+ └── config/
137
+ └── book.yml # Master typography, metadata & layout config
138
+ ```
139
+
140
+ ### 3. Build Your Book
141
+
142
+ Compile your chapters into your target formats:
143
+
144
+ ```sh
145
+ # Generate Paperback/Hardcover PDF (default: book.pdf)
146
+ booksmith build
147
+
148
+ # Generate Kindle-ready EPUB (book.epub)
149
+ booksmith build --epub
150
+
151
+ # Generate both formats simultaneously
152
+ booksmith build --format all
153
+
154
+ # Build PDF without cover page
155
+ booksmith build --no-cover
156
+ ```
157
+
158
+ ### 4. Inspect Registered Fonts
159
+
160
+ ```sh
161
+ booksmith fonts
162
+ ```
163
+
164
+ ---
165
+
166
+ ## ⚙️ Configuration Reference
167
+
168
+ All book layout options, metadata, and typography parameters are managed in `config/book.yml`:
169
+
170
+ ```yaml
171
+ ---
172
+ # Book Metadata
173
+ :title: "Modern Applications of Ninjutsu"
174
+ :author: "Authored by Leonid Medovyy"
175
+
176
+ # Font Typography
177
+ :title_font: "Noto Sans Mono"
178
+ :author_font: "Noto Sans Mono"
179
+ :header_font: "Noto Sans"
180
+ :subtitle_font: "Noto Sans Mono"
181
+ :body_font: "Noto Sans"
182
+
183
+ # Print & Page Geometry
184
+ :blank_page_after_cover: true
185
+ :table_of_contents: true
186
+ :font_size: 10.0
187
+ :line_height: 1.5
188
+ :page_numbers: true
189
+ :page_number_font_size: 9
190
+ :page_content_bottom_gap: 30
191
+
192
+ # Header Sizing (Points)
193
+ :title_font_size: 24
194
+ :author_font_size: 18
195
+ :h1_font_size: 20
196
+ :h2_font_size: 18
197
+ :h3_font_size: 14
198
+ :h4_font_size: 12
199
+
200
+ # Callout Boxes & Blockquotes
201
+ :blockquote_bg_color: "EEEEEE"
202
+ :blockquote_padding: 12
203
+ :blockquote_margin_top: 10
204
+ :blockquote_margin_bottom: 10
205
+
206
+ # Exercise Callout with Custom Icon
207
+ :exercise_blockquote_bg_color: "EEF3FF"
208
+ :exercise_blockquote_image: "assets/clock.png"
209
+ :exercise_blockquote_image_width: 18
210
+ :exercise_blockquote_image_height: 18
211
+ :exercise_blockquote_image_voffset: -2
212
+ ```
213
+
214
+ ---
215
+
216
+ ## ✍️ Markdown Authoring Guide
217
+
218
+ Booksmith supports full GitHub Flavored Markdown with custom layout enhancements:
219
+
220
+ ### Chapter Titles & Subtitles
221
+
222
+ Use standard `#` for chapter titles, followed immediately by an invisible subtitle comment:
223
+
224
+ ```markdown
225
+ # CHAPTER I
226
+ <!-- subtitle: GETTING STARTED WITH BOOKSMITH -->
227
+
228
+ Chapter opening text starts here...
229
+ ```
230
+
231
+ ### Manual Page Breaks
232
+
233
+ Insert a clean page break anywhere in your manuscript without adding visible artifacts in Markdown viewers:
234
+
235
+ ```markdown
236
+ <!-- PAGE_BREAK -->
237
+ ```
238
+
239
+ ### Styled Callout Boxes
240
+
241
+ ```markdown
242
+ > **Pro Tip**
243
+ >
244
+ > Standard callouts automatically elevate bold first lines into header titles.
245
+
246
+ > **Sidebar Note**
247
+ >
248
+ > Alternate shaded callout box for supplementary commentary.
249
+ {: .alt_blockquote}
250
+
251
+ > Hands-On Challenge
252
+ >
253
+ > Exercise callout displaying the configured icon alongside the prompt:
254
+ > - Define the core thesis of your publication
255
+ > - Configure print trim sizes in book.yml
256
+ {: .exercise_blockquote}
257
+ ```
258
+
259
+ ### Sized & Centered Images
260
+
261
+ Specify dimensions and vertical padding directly in image Markdown:
262
+
263
+ ```markdown
264
+ ![System Architecture](assets/architecture.png "width=350 top=15 bottom=15")
265
+ ```
266
+
267
+ ---
268
+
269
+ ## 📊 Feature Comparison
270
+
271
+ | Feature | Booksmith | Pandoc / LaTeX | Word / InDesign |
272
+ | :--- | :---: | :---: | :---: |
273
+ | **KDP Print-Ready Out of the Box** | ✅ Yes | ⚠️ Requires Config | ❌ Manual Formatting |
274
+ | **Simultaneous PDF & EPUB Output** | ✅ Yes | ⚠️ Separate Pipelines | ❌ Separate Exports |
275
+ | **Dynamic 2-Pass TOC Resolution** | ✅ Built-in | ⚠️ Complex LaTeX Runs | ❌ Manual TOC Updates |
276
+ | **Zero External Font Dependencies** | ✅ Bundled TTF | ❌ System Fonts | ❌ System Fonts |
277
+ | **Markdown Linter Safe Comments** | ✅ Yes (`<!-- -->`) | ❌ LaTeX Injections | ❌ N/A |
278
+ | **Transparent PNG Flattening** | ✅ Automated | ❌ Manual Preprocessing | ⚠️ Manual Flattening |
279
+
280
+ ---
281
+
282
+ ## 🛠️ CLI Command Reference
283
+
284
+ | Command | Description |
285
+ | :--- | :--- |
286
+ | `booksmith init` | Scaffolds a new book workspace with directories, default assets, sample chapter, and `book.yml`. |
287
+ | `booksmith build` | Builds `book.pdf` with Amazon KDP print specifications. |
288
+ | `booksmith build --epub` | Builds `book.epub` with validated navigation and embedded typography. |
289
+ | `booksmith build --format all` | Compiles both PDF and EPUB in a single pass. |
290
+ | `booksmith build --no-cover` | Omits the title/cover page from generation. |
291
+ | `booksmith fonts` | Lists all registered font families available for document rendering. |
292
+
293
+ ---
294
+
295
+ ## 👤 Author
296
+
297
+ Crafted by **Leonid Medovyy** ([@leouofa](https://github.com/leouofa)).
298
+
299
+ - **GitHub**: [@leouofa](https://github.com/leouofa)
300
+ - **Gem**: [rubygems.org/gems/booksmith](https://rubygems.org/gems/booksmith)
301
+
302
+ ---
303
+
304
+ ## đź“„ License
305
+
306
+ This project is open-source under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
data/bin/booksmith ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'booksmith'
4
+ Booksmith::CLI.run!
data/bin/console ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'booksmith'
5
+
6
+ require 'irb'
7
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
data/booksmith.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'booksmith/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'booksmith'
9
+ spec.version = Booksmith::VERSION
10
+ spec.authors = ['Leonid Medovyy']
11
+ spec.email = ['leonid@storypro.io']
12
+
13
+ spec.summary = 'A flexible Markdown-to-PDF and EPUB book publishing tool'
14
+ spec.description = 'Converts Markdown chapters into a styled PDF or EPUB book using Prawn, GEPUB, and Kramdown.'
15
+ spec.homepage = 'https://github.com/leouofa/booksmith'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 3.0.0'
18
+
19
+ spec.files = Dir.chdir(__dir__) do
20
+ Dir.glob('{bin,lib}/**/*').select { |f| File.file?(f) } +
21
+ %w[LICENSE.txt README.md Rakefile Gemfile booksmith.gemspec].select { |f| File.file?(f) }
22
+ end
23
+ spec.bindir = 'bin'
24
+ spec.executables = ['booksmith']
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.add_dependency 'commander', '>= 4.6'
28
+ spec.add_dependency 'gepub', '>= 1.0'
29
+ spec.add_dependency 'kramdown', '>= 2.4'
30
+ spec.add_dependency 'ostruct'
31
+ spec.add_dependency 'prawn', '>= 2.4'
32
+
33
+ spec.add_development_dependency 'bundler', '>= 2.0'
34
+ spec.add_development_dependency 'rake', '>= 12.3.3'
35
+ end
@@ -0,0 +1,228 @@
1
+ module Booksmith
2
+ module Blockquotes
3
+ private
4
+
5
+ def render_blockquote(el, style_name)
6
+ bg_color = blockquote_style_value(style_name, :bg_color, 'EEEEEE')
7
+ padding_left = blockquote_padding(style_name, :left)
8
+ padding_right = blockquote_padding(style_name, :right)
9
+ padding_top = blockquote_padding(style_name, :top)
10
+ padding_bottom = blockquote_padding(style_name, :bottom)
11
+ margin_top = blockquote_style_value(style_name, :margin_top, 10).to_f
12
+ margin_bot = blockquote_style_value(style_name, :margin_bottom, 10).to_f
13
+ content_width = @pdf.bounds.width - padding_left - padding_right
14
+ header_el, body_els = split_blockquote_children(el)
15
+
16
+ @pdf.move_down margin_top
17
+
18
+ box_height = measure_blockquote_height(el, content_width, style_name) + padding_top + padding_bottom
19
+
20
+ saved = @pdf.fill_color
21
+ @pdf.fill_color bg_color
22
+ @pdf.fill_rectangle([@pdf.bounds.left, @pdf.cursor], @pdf.bounds.width, box_height)
23
+ @pdf.fill_color saved
24
+
25
+ @pdf.move_down padding_top
26
+ with_image_flatten_bg(blockquote_image_flatten_color(style_name)) do
27
+ @pdf.indent(padding_left, padding_right) do
28
+ render_blockquote_title_row(header_el, style_name) if header_el
29
+ body_els.each { |child| convert_element(child) }
30
+ end
31
+ end
32
+ @pdf.move_down padding_bottom
33
+ @pdf.move_down margin_bot
34
+ end
35
+
36
+ def render_exercise_blockquote(el)
37
+ bg_color = blockquote_style_value(:exercise_blockquote, :bg_color, 'EEF3FF')
38
+ padding_left = blockquote_padding(:exercise_blockquote, :left)
39
+ padding_right = blockquote_padding(:exercise_blockquote, :right)
40
+ padding_top = blockquote_padding(:exercise_blockquote, :top)
41
+ padding_bottom = blockquote_padding(:exercise_blockquote, :bottom)
42
+ margin_top = blockquote_style_value(:exercise_blockquote, :margin_top, 12).to_f
43
+ margin_bot = blockquote_style_value(:exercise_blockquote, :margin_bottom, 12).to_f
44
+ content_width = @pdf.bounds.width - padding_left - padding_right
45
+
46
+ header_el, body_els = split_exercise_children(el)
47
+
48
+ @pdf.move_down margin_top
49
+
50
+ box_height = measure_exercise_height(content_width, header_el, body_els) + padding_top + padding_bottom
51
+ top_cursor = @pdf.cursor
52
+
53
+ saved = @pdf.fill_color
54
+ @pdf.fill_color bg_color
55
+ @pdf.fill_rectangle([@pdf.bounds.left, top_cursor], @pdf.bounds.width, box_height)
56
+ @pdf.fill_color saved
57
+
58
+ @pdf.move_down padding_top
59
+ with_image_flatten_bg(blockquote_image_flatten_color(:exercise_blockquote)) do
60
+ @pdf.indent(padding_left, padding_right) do
61
+ render_exercise_title_row(header_el)
62
+ body_els.each { |child| convert_element(child) }
63
+ end
64
+ end
65
+ @pdf.move_down padding_bottom
66
+ @pdf.move_down margin_bot
67
+ end
68
+
69
+ def alt_blockquote?(element)
70
+ classes = element.attr.fetch('class', '').split
71
+ classes.include?('alt_blockquote')
72
+ end
73
+
74
+ def exercise_blockquote?(element)
75
+ element.attr.fetch('class', '').split.include?('exercise_blockquote')
76
+ end
77
+
78
+ def blockquote_header_paragraph?(element)
79
+ return false unless element&.type == :p
80
+
81
+ inline = element.children.reject { |c| c.type == :blank || (c.type == :text && c.value.strip.empty?) }
82
+ inline.length == 1 && inline.first.type == :strong
83
+ end
84
+
85
+ def split_blockquote_children(el)
86
+ children = el.children.reject { |c| c.type == :blank }
87
+ return [nil, children] if children.empty?
88
+ return [children.first, children.drop(1)] if blockquote_header_paragraph?(children.first)
89
+
90
+ [nil, children]
91
+ end
92
+
93
+ def split_exercise_children(el)
94
+ children = el.children.reject { |c| c.type == :blank }
95
+ [children.first, children.drop(1)]
96
+ end
97
+
98
+ def blockquote_style_value(style_name, setting, default)
99
+ key = :"#{style_name}_#{setting}"
100
+ fallback_key = :"blockquote_#{setting}"
101
+
102
+ return @config[key] if @config.key?(key)
103
+ return @config[fallback_key] if style_name != :blockquote && @config.key?(fallback_key)
104
+
105
+ default
106
+ end
107
+
108
+ def blockquote_padding(style_name, side)
109
+ per_side_key = :"#{style_name}_padding_#{side}"
110
+ return @config[per_side_key].to_f if @config.key?(per_side_key)
111
+
112
+ if style_name != :blockquote
113
+ fallback_per_side = :"blockquote_padding_#{side}"
114
+ return @config[fallback_per_side].to_f if @config.key?(fallback_per_side)
115
+ end
116
+
117
+ default_padding = style_name == :exercise_blockquote ? 14 : 12
118
+ blockquote_style_value(style_name, :padding, default_padding).to_f
119
+ end
120
+
121
+ def blockquote_header_font_size(style_name)
122
+ blockquote_style_value(style_name, :header_font_size, @config[:font_size] + 4).to_f
123
+ end
124
+
125
+ def exercise_header_font_size
126
+ blockquote_header_font_size(:exercise_blockquote)
127
+ end
128
+
129
+ def exercise_title_row_height(img_height, title_size, header_text, title_width)
130
+ header_height = @pdf.height_of(header_text, size: title_size, style: :bold, width: title_width)
131
+ [img_height, header_height].max
132
+ end
133
+
134
+ def render_blockquote_title_row(header_el, style_name)
135
+ header_text = plain_text(header_el)
136
+ title_size = blockquote_header_font_size(style_name)
137
+ header_gap = blockquote_style_value(style_name, :header_gap, 6).to_f
138
+ row_height = @pdf.height_of(header_text, size: title_size, style: :bold, width: @pdf.bounds.width)
139
+
140
+ @pdf.text_box(
141
+ header_text,
142
+ at: [@pdf.bounds.left, @pdf.cursor],
143
+ width: @pdf.bounds.width,
144
+ height: row_height,
145
+ size: title_size,
146
+ style: :bold,
147
+ valign: :top
148
+ )
149
+ @pdf.move_down row_height + header_gap
150
+ end
151
+
152
+ def render_exercise_title_row(header_el)
153
+ header_text = plain_text(header_el)
154
+ title_size = exercise_header_font_size
155
+ img_width = blockquote_style_value(:exercise_blockquote, :image_width, 28).to_f
156
+ img_height = blockquote_style_value(:exercise_blockquote, :image_height, img_width).to_f
157
+ img_gap = blockquote_style_value(:exercise_blockquote, :image_gap, 8).to_f
158
+ img_voffset = blockquote_style_value(:exercise_blockquote, :image_voffset, 0).to_f
159
+ header_gap = blockquote_style_value(:exercise_blockquote, :header_gap, 6).to_f
160
+ title_width = [@pdf.bounds.width - img_width - img_gap, 1].max
161
+ row_height = exercise_title_row_height(img_height, title_size, header_text, title_width)
162
+
163
+ @pdf.bounding_box([@pdf.bounds.left, @pdf.cursor], width: @pdf.bounds.width, height: row_height) do
164
+ image_path = exercise_image_path
165
+ if image_path
166
+ @pdf.bounding_box([0, row_height + img_voffset], width: img_width, height: row_height) do
167
+ @pdf.image(image_path, fit: [img_width, img_height], position: :center, vposition: :center)
168
+ end
169
+ end
170
+
171
+ @pdf.text_box(
172
+ header_text,
173
+ at: [img_width + img_gap, row_height],
174
+ width: title_width,
175
+ height: row_height,
176
+ size: title_size,
177
+ style: :bold,
178
+ valign: :center
179
+ )
180
+ end
181
+
182
+ @pdf.move_down header_gap
183
+ end
184
+
185
+ def exercise_image_path
186
+ path = @config.fetch(:exercise_blockquote_image, 'assets/question_mark.png')
187
+ expanded = File.expand_path(path, Dir.pwd)
188
+ return nil unless File.exist?(expanded)
189
+
190
+ bg_color = blockquote_image_flatten_color(:exercise_blockquote)
191
+ prepare_png_path(expanded, background_color: bg_color)
192
+ end
193
+
194
+ def measure_blockquote_height(el, content_width, style_name = :blockquote)
195
+ header_el, body_els = split_blockquote_children(el)
196
+ scratch = create_scratch_document(content_width)
197
+ flatten_bg = blockquote_image_flatten_color(style_name)
198
+ converter = PDFConverter.new(scratch, @config, image_flatten_bg: flatten_bg)
199
+ start_y = scratch.cursor
200
+ if header_el
201
+ converter.send(:render_blockquote_title_row, header_el, style_name)
202
+ body_els.each { |child| converter.send(:convert_element, child) }
203
+ else
204
+ body_els.each { |child| converter.send(:convert_element, child) }
205
+ end
206
+ [start_y - scratch.cursor, 0].max
207
+ end
208
+
209
+ def measure_exercise_height(content_width, header_el, body_els)
210
+ scratch = create_scratch_document(content_width)
211
+ flatten_bg = blockquote_image_flatten_color(:exercise_blockquote)
212
+ converter = PDFConverter.new(scratch, @config, image_flatten_bg: flatten_bg)
213
+ start_y = scratch.cursor
214
+ converter.send(:render_exercise_title_row, header_el)
215
+ body_els.each { |child| converter.send(:convert_element, child) }
216
+ [start_y - scratch.cursor, 0].max
217
+ end
218
+
219
+ def create_scratch_document(content_width)
220
+ scratch = Prawn::Document.new(
221
+ page_size: [content_width + 2, 5000], margin: [0, 1, 0, 1]
222
+ )
223
+ FontPaths.register(scratch)
224
+ scratch.font(@config.fetch(:body_font, 'Noto Sans'))
225
+ scratch
226
+ end
227
+ end
228
+ end