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,313 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+ require 'stringio'
4
+
5
+ module Booksmith
6
+ class BookGenerator
7
+ SUBTITLE_COMMENT = /\A<!--\s*subtitle:\s*(.*?)\s*-->\z/i
8
+
9
+ def initialize(options)
10
+ @options = options
11
+ end
12
+
13
+ def generate
14
+ config = load_config
15
+ margins = [50, 60, 50, 60]
16
+ chapters = Dir.glob('chapters/*.md').sort
17
+ toc_enabled = config.fetch(:table_of_contents, false) && !skip_cover?
18
+
19
+ if toc_enabled
20
+ @chapters_info = collect_chapters(chapters, config)
21
+
22
+ pdf = Prawn::Document.new(page_size: 'A4', margin: margins)
23
+ FontPaths.register(pdf)
24
+ pdf.font config[:body_font]
25
+ render_cover_and_blank(pdf, config)
26
+
27
+ pre_chapter_pages = pdf.page_number
28
+ @chapters_info.each do |ch|
29
+ pdf.start_new_page
30
+ ch[:page] = pdf.page_number - pre_chapter_pages
31
+ content = File.read(ch[:file])
32
+ render_chapter_content(pdf, content, config)
33
+ end
34
+ render_page_numbers(pdf, config)
35
+
36
+ toc_page_count = estimate_toc_page_count
37
+
38
+ Prawn::Document.generate('book.pdf', page_size: 'A4', margin: margins) do |pdf|
39
+ FontPaths.register(pdf)
40
+ pdf.font config[:body_font]
41
+ render_cover_and_blank(pdf, config)
42
+ generate_toc_page(pdf, config)
43
+ generate_chapters(pdf, chapters, config, toc_offset: toc_page_count)
44
+ render_page_numbers(pdf, config, toc_offset: toc_page_count)
45
+ end
46
+ else
47
+ Prawn::Document.generate('book.pdf', page_size: 'A4', margin: margins) do |pdf|
48
+ FontPaths.register(pdf)
49
+ pdf.font config[:body_font]
50
+ render_cover_and_blank(pdf, config)
51
+ generate_chapters(pdf, chapters, config)
52
+ render_page_numbers(pdf, config)
53
+ end
54
+ end
55
+
56
+ puts '✅ Book generated successfully!'
57
+ end
58
+
59
+ def self.default_config
60
+ template_path = File.expand_path('../templates/book.yml', __dir__)
61
+ return File.read(template_path) if File.exist?(template_path)
62
+
63
+ {
64
+ title: 'My Book',
65
+ author: 'Author Name',
66
+ body_font: 'Noto Sans',
67
+ header_font: 'Noto Sans',
68
+ title_font: 'Noto Sans',
69
+ author_font: 'Noto Sans',
70
+ subtitle_font: 'Noto Sans',
71
+ title_font_size: 24,
72
+ author_font_size: 18,
73
+ title_author_spacing: 20,
74
+ font_size: 12,
75
+ h1_font_size: 20,
76
+ h2_font_size: 16,
77
+ h3_font_size: 14,
78
+ h4_font_size: 12,
79
+ line_height: 1.5,
80
+ image_flatten_bg_color: 'FFFFFF',
81
+ page_numbers: true,
82
+ page_number_font_size: 9,
83
+ page_content_bottom_gap: 0,
84
+ blank_page_after_cover: false,
85
+ table_of_contents: false,
86
+ toc_font_size: 10,
87
+ subtitle_font_size: 12,
88
+ subtitle_bold: false,
89
+ title_spacing_after: 20,
90
+ subtitle_spacing_after: 10,
91
+ list_bullet_indent: 18,
92
+ list_number_indent: 18,
93
+ list_bullet_offset: 10,
94
+ list_marker_gap: 4,
95
+ list_item_spacing: 6,
96
+ list_margin_top: 8,
97
+ list_margin_bottom: 8,
98
+ blockquote_bg_color: 'EEEEEE',
99
+ blockquote_image_flatten_bg_color: 'EEEEEE',
100
+ blockquote_padding: 12,
101
+ blockquote_padding_left: 12,
102
+ blockquote_padding_right: 12,
103
+ blockquote_padding_top: 12,
104
+ blockquote_padding_bottom: 12,
105
+ blockquote_margin_top: 10,
106
+ blockquote_margin_bottom: 10,
107
+ blockquote_header_font_size: 14,
108
+ blockquote_header_gap: 6,
109
+ alt_blockquote_bg_color: 'F5F5F5',
110
+ alt_blockquote_image_flatten_bg_color: 'F5F5F5',
111
+ alt_blockquote_padding: 12,
112
+ alt_blockquote_padding_left: 12,
113
+ alt_blockquote_padding_right: 12,
114
+ alt_blockquote_padding_top: 12,
115
+ alt_blockquote_padding_bottom: 12,
116
+ alt_blockquote_margin_top: 10,
117
+ alt_blockquote_margin_bottom: 10,
118
+ alt_blockquote_header_font_size: 14,
119
+ alt_blockquote_header_gap: 6,
120
+ exercise_blockquote_bg_color: 'EEF3FF',
121
+ exercise_blockquote_image_flatten_bg_color: 'EEF3FF',
122
+ exercise_blockquote_padding: 14,
123
+ exercise_blockquote_padding_left: 12,
124
+ exercise_blockquote_padding_right: 16,
125
+ exercise_blockquote_padding_top: 12,
126
+ exercise_blockquote_padding_bottom: 10,
127
+ exercise_blockquote_margin_top: 12,
128
+ exercise_blockquote_margin_bottom: 12,
129
+ exercise_blockquote_image: 'assets/clock.png',
130
+ exercise_blockquote_image_width: 28,
131
+ exercise_blockquote_image_height: 28,
132
+ exercise_blockquote_image_gap: 8,
133
+ exercise_blockquote_image_voffset: 0,
134
+ exercise_blockquote_header_font_size: 14,
135
+ exercise_blockquote_header_gap: 6
136
+ }.to_yaml
137
+ end
138
+
139
+ def self.sample_chapter
140
+ template_path = File.expand_path('../templates/01_introduction.md', __dir__)
141
+ return File.read(template_path) if File.exist?(template_path)
142
+
143
+ <<~CHAPTER
144
+ # Introduction
145
+
146
+ This is a sample chapter for your book.
147
+
148
+ ## Section 1
149
+
150
+ Start writing your content here...
151
+ CHAPTER
152
+ end
153
+
154
+ private
155
+
156
+ def load_config
157
+ config = YAML.load_file('config/book.yml')
158
+ config = config.transform_keys { |k| k.to_s.sub(/\A:/, '').to_sym }
159
+ config[:body_font] = config.fetch(:body_font, 'Noto Sans')
160
+ config[:header_font] = config.fetch(:header_font, config[:body_font])
161
+ config[:title_font] = config.fetch(:title_font, config[:header_font])
162
+ config[:author_font] = config.fetch(:author_font, config[:body_font])
163
+ config[:subtitle_font] = config.fetch(:subtitle_font, config[:header_font])
164
+ config[:title_font_size] = config.fetch(:title_font_size, 24)
165
+ config[:author_font_size] = config.fetch(:author_font_size, 18)
166
+ config[:title_author_spacing] = config.fetch(:title_author_spacing, 20)
167
+ config
168
+ end
169
+
170
+ def skip_cover?
171
+ return false if @options.nil?
172
+ @options.no_cover == true || @options.no_cover == false
173
+ end
174
+
175
+ def render_cover_and_blank(pdf, config)
176
+ return if skip_cover?
177
+ generate_cover(pdf, config)
178
+ pdf.start_new_page if config[:blank_page_after_cover]
179
+ end
180
+
181
+ def render_page_numbers(pdf, config, toc_offset: 0)
182
+ return unless config[:page_numbers]
183
+ page_number_font_size = config.fetch(:page_number_font_size, 9)
184
+ page_number_y = pdf.bounds.bottom + 10
185
+ skipped_pages = 0
186
+ skipped_pages += 1 unless skip_cover?
187
+ skipped_pages += 1 if !skip_cover? && config[:blank_page_after_cover]
188
+ skipped_pages += toc_offset
189
+ start_count_at = 1 - skipped_pages
190
+ page_filter = if skipped_pages.zero?
191
+ ->(_pg) { true }
192
+ else
193
+ ->(pg) { pg > skipped_pages }
194
+ end
195
+ pdf.number_pages '<page>',
196
+ start_count_at: start_count_at,
197
+ page_filter: page_filter,
198
+ align: :center,
199
+ size: page_number_font_size,
200
+ at: [0, page_number_y],
201
+ width: pdf.bounds.width
202
+ end
203
+
204
+ def collect_chapters(chapters, config)
205
+ chapters.map do |path|
206
+ content = File.read(path)
207
+ title = nil
208
+ subtitle = nil
209
+
210
+ content.each_line do |line|
211
+ stripped = line.strip
212
+ if stripped =~ /\A#\s+(.+)\z/ && title.nil?
213
+ title = Regexp.last_match(1).strip
214
+ elsif stripped =~ SUBTITLE_COMMENT && title && subtitle.nil?
215
+ subtitle = Regexp.last_match(1).strip
216
+ end
217
+ end
218
+
219
+ { file: path, title: title || File.basename(path, '.md'), subtitle: subtitle, page: 0 }
220
+ end
221
+ end
222
+
223
+ def estimate_toc_page_count
224
+ (@chapters_info.size / 25.0).ceil
225
+ end
226
+
227
+ def generate_toc_page(pdf, config)
228
+ pdf.start_new_page
229
+ toc_font_size = config.fetch(:toc_font_size, 10)
230
+ font_name = config[:subtitle_font] || 'Noto Sans Mono'
231
+
232
+ pdf.font(font_name) do
233
+ pdf.move_down 40
234
+ pdf.text 'TABLE OF CONTENTS', size: toc_font_size + 4, style: :bold, align: :center
235
+ pdf.move_down 30
236
+
237
+ char_width = pdf.width_of('M', size: toc_font_size)
238
+ page_num_area_width = pdf.width_of(' 000', size: toc_font_size)
239
+ dot_width = pdf.width_of('.', size: toc_font_size)
240
+
241
+ @chapters_info.each do |ch|
242
+ label = ch[:subtitle] ? "#{ch[:title]}: #{ch[:subtitle]}" : ch[:title]
243
+ label_width = pdf.width_of(label, size: toc_font_size)
244
+ available_for_dots = pdf.bounds.width - label_width - page_num_area_width
245
+ num_dots = [(available_for_dots / dot_width).floor, 3].max
246
+ dots = '.' * num_dots
247
+ page_str = ch[:page].to_s.rjust(3)
248
+ line = "#{label} #{dots} #{page_str}"
249
+ pdf.text line, size: toc_font_size
250
+ pdf.move_down 4
251
+ end
252
+ end
253
+ end
254
+
255
+ def render_chapter_content(pdf, content, config)
256
+ content_bottom_gap = [config.fetch(:page_content_bottom_gap, 0).to_f, 0.0].max
257
+
258
+ if content_bottom_gap > 0
259
+ max_gap = [pdf.bounds.height - 1, 0].max
260
+ gap = [content_bottom_gap, max_gap].min
261
+ pdf.bounding_box(
262
+ [pdf.bounds.left, pdf.bounds.top],
263
+ width: pdf.bounds.width,
264
+ height: pdf.bounds.height - gap
265
+ ) do
266
+ generate_chapter(pdf, content, config)
267
+ end
268
+ else
269
+ generate_chapter(pdf, content, config)
270
+ end
271
+ end
272
+
273
+ def generate_chapters(pdf, chapters, config, toc_offset: 0)
274
+ chapters.each do |chapter|
275
+ pdf.start_new_page
276
+ content = File.read(chapter)
277
+ render_chapter_content(pdf, content, config)
278
+ end
279
+ end
280
+
281
+ def generate_cover(pdf, config)
282
+ pdf.move_down 200
283
+ title_font = config[:title_font]
284
+ title_font_size = config[:title_font_size]
285
+ if title_font
286
+ pdf.font(title_font) do
287
+ pdf.text config[:title], align: :center, size: title_font_size, style: :bold
288
+ end
289
+ else
290
+ pdf.text config[:title], align: :center, size: title_font_size, style: :bold
291
+ end
292
+
293
+ pdf.move_down config[:title_author_spacing]
294
+ author_font = config[:author_font]
295
+ author_font_size = config[:author_font_size]
296
+ if author_font
297
+ pdf.font(author_font) do
298
+ pdf.text config[:author], align: :center, size: author_font_size
299
+ end
300
+ else
301
+ pdf.text config[:author], align: :center, size: author_font_size
302
+ end
303
+
304
+ pdf.move_down 10
305
+ pdf.text "Version #{config[:version]}", align: :center, size: 12 if config[:version]
306
+ end
307
+
308
+ def generate_chapter(pdf, content, config)
309
+ converter = PDFConverter.new(pdf, config)
310
+ converter.convert(content)
311
+ end
312
+ end
313
+ end