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.
@@ -0,0 +1,681 @@
1
+ require 'gepub'
2
+ require 'kramdown'
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+ require 'stringio'
7
+ require 'ostruct'
8
+
9
+ require_relative 'font_paths'
10
+ require_relative 'headers'
11
+
12
+ module Booksmith
13
+ class EpubGenerator
14
+ SUBTITLE_COMMENT = /\A<!--\s*subtitle:\s*(.*?)\s*-->\z/i
15
+ PAGE_BREAK_COMMENT = /\A<!--\s*PAGE_BREAK\s*-->\z/i
16
+
17
+ def initialize(options = nil)
18
+ @options = options || OpenStruct.new
19
+ end
20
+
21
+ def generate
22
+ config = load_config
23
+ chapters = Dir.glob('chapters/*.md').sort
24
+
25
+ book = GEPUB::Book.new
26
+ book.title = config[:title] || 'Untitled Book'
27
+ book.creator = config[:author] || 'Unknown Author'
28
+ book.language = config[:language] || 'en'
29
+ book.identifier = config[:identifier] || "urn:uuid:#{SecureRandom.uuid}"
30
+ book.date = config[:date] || Time.now.strftime('%Y-%m-%d')
31
+
32
+ added_assets = {}
33
+
34
+ # 1. Embed Fonts
35
+ embed_fonts(book, added_assets)
36
+
37
+ # 2. Add Stylesheet
38
+ embed_stylesheet(book, config)
39
+
40
+ # 3. Add Exercise Icon
41
+ exercise_icon_filename = embed_exercise_icon(book, config, added_assets)
42
+
43
+ # 4. Add Cover Image if configured
44
+ embed_cover_image(book, config, added_assets)
45
+
46
+ # 5. Process Chapters
47
+ processed_chapters = process_chapters(chapters, config, exercise_icon_filename, book, added_assets)
48
+
49
+ # 6. Build Navigation and Spine
50
+ book.ordered do
51
+ unless skip_cover?
52
+ cover_xhtml = generate_cover_page(config)
53
+ cover_item = book.add_item('text/cover.xhtml', content: StringIO.new(cover_xhtml))
54
+ cover_item.toc_text('Cover')
55
+ end
56
+
57
+ processed_chapters.each do |ch|
58
+ item = book.add_item("text/#{ch[:filename]}", content: StringIO.new(ch[:xhtml]))
59
+ item.toc_text(ch[:toc_title])
60
+ end
61
+ end
62
+
63
+ output_path = @options.output || 'book.epub'
64
+ book.generate_epub(output_path)
65
+ puts "✅ EPUB generated successfully at #{output_path}!"
66
+ end
67
+
68
+ private
69
+
70
+ def skip_cover?
71
+ return false if @options.nil?
72
+ # OptionParser sets no_cover = false when --no-cover flag is passed
73
+ @options.no_cover == true || @options.no_cover == false
74
+ end
75
+
76
+ def load_config
77
+ config = YAML.load_file('config/book.yml')
78
+ config = config.transform_keys { |k| k.to_s.sub(/\A:/, '').to_sym }
79
+ config[:body_font] = config.fetch(:body_font, 'Noto Sans')
80
+ config[:header_font] = config.fetch(:header_font, config[:body_font])
81
+ config[:title_font] = config.fetch(:title_font, config[:header_font])
82
+ config[:author_font] = config.fetch(:author_font, config[:body_font])
83
+ config[:subtitle_font] = config.fetch(:subtitle_font, config[:header_font])
84
+ config[:title_font_size] = config.fetch(:title_font_size, 24)
85
+ config[:author_font_size] = config.fetch(:author_font_size, 18)
86
+ config[:subtitle_font_size] = config.fetch(:subtitle_font_size, 12)
87
+ config
88
+ end
89
+
90
+ def embed_fonts(book, added_assets)
91
+ FontPaths.all_files.each do |font_path|
92
+ next unless File.file?(font_path)
93
+
94
+ filename = File.basename(font_path)
95
+ href = "fonts/#{filename}"
96
+ next if added_assets[href]
97
+
98
+ added_assets[href] = true
99
+ item = book.add_item(href, content: File.open(font_path, 'rb'))
100
+ item.media_type = 'font/ttf'
101
+ end
102
+ end
103
+
104
+ def embed_stylesheet(book, config)
105
+ css_content = generate_css(config)
106
+ book.add_item('css/styles.css', content: StringIO.new(css_content))
107
+ end
108
+
109
+ def embed_exercise_icon(book, config, added_assets)
110
+ icon_path = config[:exercise_blockquote_image] || 'assets/clock.png'
111
+ return nil unless File.file?(icon_path)
112
+
113
+ filename = File.basename(icon_path)
114
+ href = "images/#{filename}"
115
+ unless added_assets[href]
116
+ added_assets[href] = true
117
+ book.add_item(href, content: File.open(icon_path, 'rb'))
118
+ end
119
+ filename
120
+ end
121
+
122
+ def embed_cover_image(book, config, added_assets)
123
+ cover_image_path = config[:cover_image]
124
+ return unless cover_image_path && File.file?(cover_image_path)
125
+
126
+ filename = File.basename(cover_image_path)
127
+ href = "images/#{filename}"
128
+ return if added_assets[href]
129
+
130
+ added_assets[href] = true
131
+ book.add_item(href, content: File.open(cover_image_path, 'rb')).cover_image
132
+ end
133
+
134
+ def process_chapters(chapters, config, exercise_icon_filename, book, added_assets)
135
+ chapters.each_with_index.map do |chapter_path, idx|
136
+ raw_content = File.read(chapter_path)
137
+ content = Headers.normalize_consecutive_headers(raw_content)
138
+
139
+ title, subtitle = extract_title_and_subtitle(content, chapter_path)
140
+ toc_title = subtitle ? "#{title}: #{subtitle}" : title
141
+
142
+ chapter_metrics = measure_chapter_images(content, config)
143
+
144
+ doc = Kramdown::Document.new(content)
145
+ html = doc.to_html
146
+
147
+ processed_html = transform_html(html, title, subtitle, exercise_icon_filename, book, added_assets, chapter_metrics)
148
+ xhtml = wrap_in_xhtml(title, processed_html)
149
+
150
+ filename = "chapter_#{format('%02d', idx)}.xhtml"
151
+ { filename: filename, xhtml: xhtml, toc_title: toc_title }
152
+ end
153
+ end
154
+
155
+ def extract_title_and_subtitle(content, file_path)
156
+ title = nil
157
+ subtitle = nil
158
+
159
+ content.each_line do |line|
160
+ stripped = line.strip
161
+ if stripped =~ /\A#\s+(.+)\z/ && title.nil?
162
+ title = Regexp.last_match(1).strip
163
+ elsif stripped =~ SUBTITLE_COMMENT && title && subtitle.nil?
164
+ subtitle = Regexp.last_match(1).strip
165
+ end
166
+ end
167
+
168
+ [title || File.basename(file_path, '.md'), subtitle]
169
+ end
170
+
171
+ def transform_html(html, title, subtitle, exercise_icon_filename, book, added_assets, chapter_metrics = {})
172
+ result = html.dup
173
+
174
+ # 1. Transform chapter header with subtitle
175
+ header_replaced = false
176
+ result = result.sub(%r{<h1([^>]*)>(.*?)</h1>\s*<!--\s*subtitle:\s*(.*?)\s*-->}mi) do
177
+ h1_attrs = Regexp.last_match(1)
178
+ h1_text = Regexp.last_match(2)
179
+ sub_text = Regexp.last_match(3)
180
+ header_replaced = true
181
+ <<~HTML.strip
182
+ <div class="chapter-header">
183
+ <h1#{h1_attrs} class="chapter-title">#{h1_text}</h1>
184
+ <p class="chapter-subtitle">#{sub_text}</p>
185
+ </div>
186
+ HTML
187
+ end
188
+
189
+ unless header_replaced
190
+ result = result.sub(%r{<h1([^>]*)>(.*?)</h1>}m) do
191
+ h1_attrs = Regexp.last_match(1)
192
+ h1_text = Regexp.last_match(2)
193
+ subtitle_part = subtitle ? %(\n <p class="chapter-subtitle">#{subtitle}</p>) : ''
194
+ <<~HTML.strip
195
+ <div class="chapter-header">
196
+ <h1#{h1_attrs} class="chapter-title">#{h1_text}</h1>#{subtitle_part}
197
+ </div>
198
+ HTML
199
+ end
200
+ end
201
+
202
+ # Remove leftover standalone subtitle comments if any
203
+ result = result.gsub(%r{<!--\s*subtitle:\s*.*?\s*-->}mi, '')
204
+
205
+ # 2. Transform PAGE_BREAK comments
206
+ result = result.gsub(%r{<!--\s*PAGE_BREAK\s*-->}i, '<div class="page-break"></div>')
207
+
208
+ # 3. Transform images and register them in the EPUB
209
+ result = transform_images(result, book, added_assets, chapter_metrics)
210
+
211
+ # 4. Transform blockquotes
212
+ result = transform_blockquotes(result, exercise_icon_filename)
213
+
214
+ result
215
+ end
216
+
217
+ def transform_blockquotes(html, exercise_icon_filename)
218
+ html.gsub(%r{<blockquote(\s+class="([^"]*)")?>(.*?)</blockquote>}m) do
219
+ attrs = Regexp.last_match(1) || ''
220
+ classes = (Regexp.last_match(2) || '').split
221
+ inner = Regexp.last_match(3)
222
+ is_exercise = classes.include?('exercise_blockquote')
223
+
224
+ # Check if first paragraph starts with bold text
225
+ if inner =~ %r{\A\s*<p>\s*<strong>(.*?)</strong>(?:\s*<br\s*/?>|\s*\n)?\s*(.*?)\s*</p>(.*)\z}m
226
+ header_text = Regexp.last_match(1)
227
+ p_rest = Regexp.last_match(2)
228
+ remaining = Regexp.last_match(3)
229
+
230
+ header_html = if is_exercise && exercise_icon_filename
231
+ %(<div class="blockquote-header"><img src="../images/#{exercise_icon_filename}" class="exercise-icon" alt="" /><span>#{header_text}</span></div>)
232
+ else
233
+ %(<div class="blockquote-header"><span>#{header_text}</span></div>)
234
+ end
235
+
236
+ body_html = (p_rest && !p_rest.empty?) ? %(<p>#{p_rest}</p>#{remaining}) : remaining
237
+ %(<blockquote#{attrs}>\n #{header_html}\n #{body_html.strip}\n</blockquote>)
238
+ elsif is_exercise && exercise_icon_filename
239
+ icon_html = %(<div class="blockquote-icon-row"><img src="../images/#{exercise_icon_filename}" class="exercise-icon" alt="" /></div>)
240
+ %(<blockquote#{attrs}>\n #{icon_html}\n #{inner.strip}\n</blockquote>)
241
+ else
242
+ %(<blockquote#{attrs}>#{inner}</blockquote>)
243
+ end
244
+ end
245
+ end
246
+
247
+ def transform_images(html, book, added_assets, chapter_metrics = {})
248
+ # First transform paragraph-wrapped images
249
+ result = html.gsub(%r{<p>\s*(<img\b[^>]*/>)\s*</p>}m) do
250
+ img_tag = Regexp.last_match(1)
251
+ convert_single_image(img_tag, book, added_assets, chapter_metrics)
252
+ end
253
+
254
+ # Then transform any remaining standalone images outside image-container
255
+ result.gsub(%r{(<div class="image-container"[^>]*>.*?</div>)|(<img\b[^>]*/>)}m) do
256
+ if Regexp.last_match(1)
257
+ Regexp.last_match(1)
258
+ else
259
+ convert_single_image(Regexp.last_match(2), book, added_assets, chapter_metrics)
260
+ end
261
+ end
262
+ end
263
+
264
+ def convert_single_image(img_tag, book, added_assets, chapter_metrics = {})
265
+ src_match = img_tag.match(%r{src="([^"]+)"})
266
+ return img_tag unless src_match
267
+
268
+ original_src = src_match[1]
269
+ resolved_path = resolve_image_path(original_src)
270
+ return img_tag unless resolved_path && File.file?(resolved_path)
271
+
272
+ filename = File.basename(resolved_path)
273
+ epub_image_href = "images/#{filename}"
274
+
275
+ unless added_assets[epub_image_href]
276
+ added_assets[epub_image_href] = true
277
+ book.add_item(epub_image_href, content: File.open(resolved_path, 'rb'))
278
+ end
279
+
280
+ new_src = "../images/#{filename}"
281
+
282
+ metric = chapter_metrics[original_src]
283
+ if metric
284
+ width_pct = metric[:pct]
285
+ max_w_px = metric[:rendered_width].round
286
+ margin_top = [metric[:top], 30.0].min.round
287
+ margin_bottom = [metric[:bottom], 20.0].min.round
288
+ else
289
+ width_match = img_tag.match(/width=(\d+(?:\.\d+)?)/)
290
+ w = width_match ? width_match[1].to_f : 400.0
291
+ width_pct = [(w / 475.28 * 100.0).round(1), 100.0].min
292
+ max_w_px = w.round
293
+ top_match = img_tag.match(/top=(\d+(?:\.\d+)?)/)
294
+ margin_top = top_match ? [top_match[1].to_f, 30.0].min.round : 10
295
+ bot_match = img_tag.match(/bottom=(\d+(?:\.\d+)?)/)
296
+ margin_bottom = bot_match ? [bot_match[1].to_f, 20.0].min.round : 10
297
+ end
298
+
299
+ alt_match = img_tag.match(/alt="([^"]*)"/)
300
+ alt_attr = alt_match ? %(alt="#{alt_match[1]}") : 'alt=""'
301
+
302
+ # Container styles: constrain width and center horizontally
303
+ container_style = %(style="width: #{width_pct}%; max-width: #{max_w_px}px; margin: #{margin_top}px auto #{margin_bottom}px auto;")
304
+
305
+ # Image styles: fit container width, preserve aspect ratio, capped at 42vh
306
+ img_style = %(style="width: 100%; max-width: 100%; max-height: 42vh; height: auto; object-fit: contain; display: block; margin: 0 auto;")
307
+
308
+ new_tag = %(<img src="#{new_src}" #{alt_attr} width="#{max_w_px}" #{img_style} class="book-image" />)
309
+ %(<div class="image-container" #{container_style}>#{new_tag}</div>)
310
+ end
311
+
312
+ def resolve_image_path(src)
313
+ return src if File.file?(src)
314
+
315
+ candidate = File.join(Dir.pwd, src)
316
+ return candidate if File.file?(candidate)
317
+
318
+ candidate = File.join(Dir.pwd, 'assets', File.basename(src))
319
+ return candidate if File.file?(candidate)
320
+
321
+ nil
322
+ end
323
+
324
+ def measure_chapter_images(content, config)
325
+ margins = [50, 60, 50, 60]
326
+ pdf = Prawn::Document.new(page_size: 'A4', margin: margins)
327
+ FontPaths.register(pdf)
328
+ pdf.font config[:body_font]
329
+
330
+ normalized = Headers.normalize_consecutive_headers(content)
331
+ doc = Kramdown::Document.new(normalized)
332
+ converter = PDFConverter.new(pdf, config)
333
+
334
+ metrics = {}
335
+ doc.root.children.each do |el|
336
+ img = converter.send(:image_child, el)
337
+ if img
338
+ opts = img.attr['title'].to_s
339
+ src = img.attr['src']
340
+ w = opts[/width=(\d+(?:\.\d+)?)/, 1]&.to_f
341
+ top = opts[/top=(\d+(?:\.\d+)?)/, 1]&.to_f || 0
342
+ bottom = opts[/bottom=(\d+(?:\.\d+)?)/, 1]&.to_f || 0
343
+ cursor_before = pdf.cursor
344
+ max_w = w || pdf.bounds.width
345
+ max_h = [cursor_before - top - 20, 50.0].max
346
+
347
+ resolved = resolve_image_path(src)
348
+ if resolved && File.file?(resolved)
349
+ dims = image_dimensions(resolved)
350
+ if dims && dims[0] > 0 && dims[1] > 0
351
+ orig_w, orig_h = dims[0].to_f, dims[1].to_f
352
+ scale = [max_w / orig_w, max_h / orig_h].min
353
+ rendered_w = orig_w * scale
354
+ rendered_h = orig_h * scale
355
+ col_w = pdf.bounds.width.to_f
356
+ pct = [(rendered_w / col_w * 100.0).round(1), 100.0].min
357
+ metrics[src] = {
358
+ rendered_width: rendered_w.round(1),
359
+ rendered_height: rendered_h.round(1),
360
+ pct: pct,
361
+ top: top,
362
+ bottom: bottom
363
+ }
364
+ end
365
+ end
366
+ end
367
+ converter.send(:convert_element, el)
368
+ end
369
+ metrics
370
+ rescue => _e
371
+ {}
372
+ end
373
+
374
+ def image_dimensions(path)
375
+ return nil unless File.file?(path)
376
+
377
+ if path.downcase.end_with?('.png')
378
+ data = File.binread(path, 24)
379
+ if data && data.bytesize >= 24 && data[0..7] == "\x89PNG\r\n\x1a\n".b
380
+ return data[16..23].unpack('NN')
381
+ end
382
+ end
383
+
384
+ output = `magick identify -format "%w %h" "#{path}" 2>/dev/null`
385
+ if output && !output.empty?
386
+ parts = output.split.map(&:to_i)
387
+ return parts if parts.length == 2 && parts[0] > 0
388
+ end
389
+
390
+ nil
391
+ end
392
+
393
+ def wrap_in_xhtml(title, body_content)
394
+ escaped_title = escape_html(title)
395
+ <<~XHTML
396
+ <?xml version="1.0" encoding="utf-8"?>
397
+ <!DOCTYPE html>
398
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en">
399
+ <head>
400
+ <meta charset="utf-8"/>
401
+ <title>#{escaped_title}</title>
402
+ <link rel="stylesheet" type="text/css" href="../css/styles.css"/>
403
+ </head>
404
+ <body>
405
+ #{body_content}
406
+ </body>
407
+ </html>
408
+ XHTML
409
+ end
410
+
411
+ def generate_cover_page(config)
412
+ title = escape_html(config[:title] || 'Untitled')
413
+ author = escape_html(config[:author] || '')
414
+ version = config[:version] ? escape_html("Version #{config[:version]}") : nil
415
+
416
+ version_html = version ? %(<div class="cover-version">#{version}</div>) : ''
417
+
418
+ <<~XHTML
419
+ <?xml version="1.0" encoding="utf-8"?>
420
+ <!DOCTYPE html>
421
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en">
422
+ <head>
423
+ <meta charset="utf-8"/>
424
+ <title>#{title}</title>
425
+ <link rel="stylesheet" type="text/css" href="../css/styles.css"/>
426
+ </head>
427
+ <body>
428
+ <div class="cover-page">
429
+ <h1 class="cover-title">#{title}</h1>
430
+ <div class="cover-author">#{author}</div>
431
+ #{version_html}
432
+ </div>
433
+ </body>
434
+ </html>
435
+ XHTML
436
+ end
437
+
438
+ def generate_css(config)
439
+ font_size = config.fetch(:font_size, 10)
440
+ line_height = config.fetch(:line_height, 1.5)
441
+ h1_size = config.fetch(:h1_font_size, 20)
442
+ h2_size = config.fetch(:h2_font_size, 18)
443
+ h3_size = config.fetch(:h3_font_size, 14)
444
+ h4_size = config.fetch(:h4_font_size, 12)
445
+ subtitle_size = config.fetch(:subtitle_font_size, 12)
446
+ subtitle_weight = config[:subtitle_bold] ? 'bold' : 'normal'
447
+ title_font_size = config.fetch(:title_font_size, 24)
448
+ author_font_size = config.fetch(:author_font_size, 18)
449
+
450
+ bq_bg = config.fetch(:blockquote_bg_color, 'EEEEEE')
451
+ bq_pt = config.fetch(:blockquote_padding_top, 20)
452
+ bq_pr = config.fetch(:blockquote_padding_right, 12)
453
+ bq_pb = config.fetch(:blockquote_padding_bottom, 10)
454
+ bq_pl = config.fetch(:blockquote_padding_left, 12)
455
+ bq_header_size = config.fetch(:blockquote_header_font_size, 14)
456
+
457
+ alt_bq_bg = config.fetch(:alt_blockquote_bg_color, 'F5F5F5')
458
+ alt_bq_pt = config.fetch(:alt_blockquote_padding_top, 18)
459
+ alt_bq_pr = config.fetch(:alt_blockquote_padding_right, 12)
460
+ alt_bq_pb = config.fetch(:alt_blockquote_padding_bottom, 8)
461
+ alt_bq_pl = config.fetch(:alt_blockquote_padding_left, 12)
462
+
463
+ ex_bq_bg = config.fetch(:exercise_blockquote_bg_color, 'EEF3FF')
464
+ ex_bq_pt = config.fetch(:exercise_blockquote_padding_top, 18)
465
+ ex_bq_pr = config.fetch(:exercise_blockquote_padding_right, 16)
466
+ ex_bq_pb = config.fetch(:exercise_blockquote_padding_bottom, 10)
467
+ ex_bq_pl = config.fetch(:exercise_blockquote_padding_left, 12)
468
+ ex_icon_width = config.fetch(:exercise_blockquote_image_width, 18)
469
+ ex_icon_height = config.fetch(:exercise_blockquote_image_height, 18)
470
+
471
+ <<~CSS
472
+ @font-face {
473
+ font-family: 'Noto Sans';
474
+ font-style: normal;
475
+ font-weight: 400;
476
+ src: url('../fonts/NotoSans-Regular.ttf');
477
+ }
478
+ @font-face {
479
+ font-family: 'Noto Sans';
480
+ font-style: normal;
481
+ font-weight: 700;
482
+ src: url('../fonts/NotoSans-Bold.ttf');
483
+ }
484
+ @font-face {
485
+ font-family: 'Noto Sans';
486
+ font-style: italic;
487
+ font-weight: 400;
488
+ src: url('../fonts/NotoSans-Italic.ttf');
489
+ }
490
+ @font-face {
491
+ font-family: 'Noto Sans';
492
+ font-style: italic;
493
+ font-weight: 700;
494
+ src: url('../fonts/NotoSans-BoldItalic.ttf');
495
+ }
496
+ @font-face {
497
+ font-family: 'Noto Sans Mono';
498
+ font-style: normal;
499
+ font-weight: 400;
500
+ src: url('../fonts/NotoSansMono-Regular.ttf');
501
+ }
502
+ @font-face {
503
+ font-family: 'Noto Sans Mono';
504
+ font-style: normal;
505
+ font-weight: 700;
506
+ src: url('../fonts/NotoSansMono-Bold.ttf');
507
+ }
508
+
509
+ body {
510
+ font-family: 'Noto Sans', system-ui, -apple-system, sans-serif;
511
+ font-size: #{font_size}pt;
512
+ line-height: 1.4;
513
+ color: #222222;
514
+ margin: 0;
515
+ padding: 0 0.5em;
516
+ }
517
+
518
+ h1, h2, h3, h4, h5, h6 {
519
+ font-family: 'Noto Sans', sans-serif;
520
+ font-weight: bold;
521
+ line-height: 1.25;
522
+ margin-top: 1.4em;
523
+ margin-bottom: 0.5em;
524
+ }
525
+
526
+ h1 { font-size: #{h1_size}pt; }
527
+ h2 { font-size: #{h2_size}pt; }
528
+ h3 { font-size: #{h3_size}pt; }
529
+ h4 { font-size: #{h4_size}pt; }
530
+
531
+ .chapter-header {
532
+ margin-top: 0.5em;
533
+ margin-bottom: 1.2em;
534
+ }
535
+
536
+ .chapter-title {
537
+ font-size: #{h1_size}pt;
538
+ margin: 0 0 0.3em 0;
539
+ }
540
+
541
+ .chapter-subtitle {
542
+ font-family: 'Noto Sans Mono', monospace;
543
+ font-size: #{subtitle_size}pt;
544
+ font-weight: #{subtitle_weight};
545
+ color: #555555;
546
+ margin: 0 0 1.2em 0;
547
+ }
548
+
549
+ p {
550
+ margin-top: 0;
551
+ margin-bottom: 1em;
552
+ }
553
+
554
+ ul, ol {
555
+ margin-top: 0.5em;
556
+ margin-bottom: 1em;
557
+ padding-left: 1.8em;
558
+ }
559
+
560
+ li {
561
+ margin-bottom: 0.4em;
562
+ }
563
+
564
+ pre, code {
565
+ font-family: 'Noto Sans Mono', monospace;
566
+ }
567
+
568
+ pre {
569
+ background-color: #f7f7f7;
570
+ border: 1px solid #e5e5e5;
571
+ border-radius: 4px;
572
+ padding: 10px 14px;
573
+ overflow-x: auto;
574
+ font-size: 0.9em;
575
+ line-height: 1.4;
576
+ }
577
+
578
+ code {
579
+ background-color: #f0f0f0;
580
+ padding: 0.15em 0.35em;
581
+ border-radius: 3px;
582
+ font-size: 0.9em;
583
+ }
584
+
585
+ pre code {
586
+ background-color: transparent;
587
+ padding: 0;
588
+ }
589
+
590
+ blockquote {
591
+ background-color: ##{bq_bg};
592
+ margin: 1.2em 0;
593
+ padding: #{bq_pt}px #{bq_pr}px #{bq_pb}px #{bq_pl}px;
594
+ border-radius: 4px;
595
+ }
596
+
597
+ blockquote.alt_blockquote {
598
+ background-color: ##{alt_bq_bg};
599
+ padding: #{alt_bq_pt}px #{alt_bq_pr}px #{alt_bq_pb}px #{alt_bq_pl}px;
600
+ }
601
+
602
+ blockquote.exercise_blockquote {
603
+ background-color: ##{ex_bq_bg};
604
+ padding: #{ex_bq_pt}px #{ex_bq_pr}px #{ex_bq_pb}px #{ex_bq_pl}px;
605
+ }
606
+
607
+ .blockquote-header {
608
+ font-size: #{bq_header_size}pt;
609
+ font-weight: bold;
610
+ margin-bottom: 0.5em;
611
+ display: flex;
612
+ align-items: center;
613
+ }
614
+
615
+ .exercise-icon {
616
+ width: #{ex_icon_width}px;
617
+ height: #{ex_icon_height}px;
618
+ vertical-align: middle;
619
+ margin-right: 8px;
620
+ display: inline-block;
621
+ }
622
+
623
+ .blockquote-icon-row {
624
+ margin-bottom: 0.5em;
625
+ }
626
+
627
+ .page-break {
628
+ page-break-after: always;
629
+ break-after: page;
630
+ }
631
+
632
+ .image-container {
633
+ text-align: center;
634
+ margin-left: auto;
635
+ margin-right: auto;
636
+ }
637
+
638
+ .book-image {
639
+ max-width: 100%;
640
+ height: auto;
641
+ display: block;
642
+ margin: 0 auto;
643
+ }
644
+
645
+ /* Cover page */
646
+ .cover-page {
647
+ text-align: center;
648
+ padding-top: 25vh;
649
+ }
650
+
651
+ .cover-title {
652
+ font-family: 'Noto Sans Mono', monospace;
653
+ font-size: #{title_font_size}pt;
654
+ font-weight: bold;
655
+ margin-bottom: 20px;
656
+ }
657
+
658
+ .cover-author {
659
+ font-family: 'Noto Sans Mono', monospace;
660
+ font-size: #{author_font_size}pt;
661
+ color: #444444;
662
+ margin-bottom: 15px;
663
+ }
664
+
665
+ .cover-version {
666
+ font-size: 12pt;
667
+ color: #888888;
668
+ }
669
+ CSS
670
+ end
671
+
672
+ def escape_html(str)
673
+ str.to_s
674
+ .gsub('&', '&amp;')
675
+ .gsub('<', '&lt;')
676
+ .gsub('>', '&gt;')
677
+ .gsub('"', '&quot;')
678
+ .gsub("'", '&#39;')
679
+ end
680
+ end
681
+ end