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 +7 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +306 -0
- data/Rakefile +3 -0
- data/bin/booksmith +4 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/booksmith.gemspec +35 -0
- data/lib/booksmith/blockquotes.rb +228 -0
- data/lib/booksmith/book_generator.rb +313 -0
- data/lib/booksmith/epub_generator.rb +681 -0
- data/lib/booksmith/font_paths.rb +56 -0
- data/lib/booksmith/headers.rb +97 -0
- data/lib/booksmith/images.rb +92 -0
- data/lib/booksmith/lists.rb +62 -0
- data/lib/booksmith/text.rb +56 -0
- data/lib/booksmith/version.rb +5 -0
- data/lib/booksmith.rb +218 -0
- data/lib/fonts/NotoSans-Bold.ttf +0 -0
- data/lib/fonts/NotoSans-BoldItalic.ttf +0 -0
- data/lib/fonts/NotoSans-Italic.ttf +0 -0
- data/lib/fonts/NotoSans-Regular.ttf +0 -0
- data/lib/fonts/NotoSansMono-Bold.ttf +0 -0
- data/lib/fonts/NotoSansMono-Regular.ttf +0 -0
- data/lib/templates/01_introduction.md +58 -0
- data/lib/templates/book.yml +99 -0
- data/lib/templates/clock.png +0 -0
- metadata +167 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Booksmith
|
|
2
|
+
module FontPaths
|
|
3
|
+
NOTO_SANS_FILES = {
|
|
4
|
+
normal: 'NotoSans-Regular.ttf',
|
|
5
|
+
bold: 'NotoSans-Bold.ttf',
|
|
6
|
+
italic: 'NotoSans-Italic.ttf',
|
|
7
|
+
bold_italic: 'NotoSans-BoldItalic.ttf'
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
NOTO_SANS_MONO_FILES = {
|
|
11
|
+
normal: 'NotoSansMono-Regular.ttf',
|
|
12
|
+
bold: 'NotoSansMono-Bold.ttf'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.search_dirs
|
|
16
|
+
[
|
|
17
|
+
File.join(Dir.pwd, 'assets', 'fonts'),
|
|
18
|
+
File.expand_path('../fonts', __dir__),
|
|
19
|
+
File.expand_path('~/Library/Fonts'),
|
|
20
|
+
'/usr/share/fonts/noto',
|
|
21
|
+
'/usr/share/fonts/truetype/noto',
|
|
22
|
+
'/usr/local/share/fonts/noto',
|
|
23
|
+
*Dir.glob('/opt/homebrew/Caskroom/font-noto-sans/*/NotoSans/unhinted/ttf'),
|
|
24
|
+
*Dir.glob('/opt/homebrew/Caskroom/font-noto-sans-mono/*/NotoSansMono/unhinted/ttf'),
|
|
25
|
+
*Dir.glob('/usr/local/Caskroom/font-noto-sans/*/NotoSans/unhinted/ttf'),
|
|
26
|
+
*Dir.glob('/usr/local/Caskroom/font-noto-sans-mono/*/NotoSansMono/unhinted/ttf')
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.resolve(filename)
|
|
31
|
+
search_dirs.each do |dir|
|
|
32
|
+
path = File.join(dir, filename)
|
|
33
|
+
return path if File.file?(path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise <<~MSG.strip
|
|
37
|
+
#{filename} not found. Install Noto fonts (e.g. `brew install --cask font-noto-sans font-noto-sans-mono`) or add font files to assets/fonts/.
|
|
38
|
+
MSG
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.families
|
|
42
|
+
{
|
|
43
|
+
'Noto Sans' => NOTO_SANS_FILES.transform_values { |file| resolve(file) },
|
|
44
|
+
'Noto Sans Mono' => NOTO_SANS_MONO_FILES.transform_values { |file| resolve(file) }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.all_files
|
|
49
|
+
families.values.flat_map(&:values).uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.register(pdf)
|
|
53
|
+
pdf.font_families.update(families)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Booksmith
|
|
2
|
+
module Headers
|
|
3
|
+
private
|
|
4
|
+
|
|
5
|
+
SUBTITLE_COMMENT = /\A<!--\s*subtitle:\s*(.*?)\s*-->\z/i
|
|
6
|
+
PAGE_BREAK_COMMENT = /\A<!--\s*PAGE_BREAK\s*-->\z/i
|
|
7
|
+
|
|
8
|
+
# Kramdown requires a blank line between consecutive headers.
|
|
9
|
+
def self.normalize_consecutive_headers(content)
|
|
10
|
+
lines = content.lines
|
|
11
|
+
normalized = []
|
|
12
|
+
lines.each_with_index do |line, idx|
|
|
13
|
+
normalized << line
|
|
14
|
+
next_line = lines[idx + 1]
|
|
15
|
+
if line.match?(/\A\#{1,6}\s/) && next_line&.match?(/\A\#{1,6}\s/)
|
|
16
|
+
normalized << "\n"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
normalized.join
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def normalize_consecutive_headers(content)
|
|
23
|
+
Headers.normalize_consecutive_headers(content)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def subtitle_comment?(el)
|
|
27
|
+
el&.type == :xml_comment && el.value.to_s =~ SUBTITLE_COMMENT
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def subtitle_comment_text(el)
|
|
31
|
+
el.value.to_s[SUBTITLE_COMMENT, 1]&.strip
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_subtitle(text)
|
|
35
|
+
size = @config.fetch(:subtitle_font_size, @config[:h4_font_size])
|
|
36
|
+
style = @config.fetch(:subtitle_bold, false) ? :bold : nil
|
|
37
|
+
font_name = @config[:subtitle_font]
|
|
38
|
+
if font_name
|
|
39
|
+
@pdf.font(font_name) do
|
|
40
|
+
@pdf.text text, size: size, style: style
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
@pdf.text text, size: size, style: style
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def render_header(el, prev:, next_el:)
|
|
48
|
+
prev_is_page_break = prev&.type == :xml_comment && prev.value.to_s =~ PAGE_BREAK_COMMENT
|
|
49
|
+
if prev&.type == :header || prev_is_page_break
|
|
50
|
+
@pdf.move_down 0
|
|
51
|
+
else
|
|
52
|
+
@pdf.move_down 20
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
level = el.options[:level]
|
|
56
|
+
|
|
57
|
+
header_sizes = {
|
|
58
|
+
1 => @config[:h1_font_size],
|
|
59
|
+
2 => @config[:h2_font_size],
|
|
60
|
+
3 => @config[:h3_font_size],
|
|
61
|
+
4 => @config[:h4_font_size]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
size = header_sizes[level]
|
|
65
|
+
raise KeyError, "Missing required header font size config for H#{level} (:h#{level}_font_size)" unless size
|
|
66
|
+
|
|
67
|
+
font_name = @config[:header_font]
|
|
68
|
+
if font_name
|
|
69
|
+
@pdf.font(font_name) do
|
|
70
|
+
@pdf.text el.options[:raw_text], size: size, style: :bold
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
@pdf.text el.options[:raw_text], size: size, style: :bold
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
title_spacing_after = @config.fetch(:title_spacing_after, 20)
|
|
77
|
+
if level == 1 && subtitle_comment?(next_el)
|
|
78
|
+
@pdf.move_down 0
|
|
79
|
+
elsif next_el&.type == :header
|
|
80
|
+
@pdf.move_down 0
|
|
81
|
+
elsif level == 1
|
|
82
|
+
@pdf.move_down title_spacing_after
|
|
83
|
+
else
|
|
84
|
+
@pdf.move_down 14
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_xml_comment(el, prev:)
|
|
89
|
+
if el.value.to_s =~ PAGE_BREAK_COMMENT
|
|
90
|
+
@pdf.start_new_page
|
|
91
|
+
elsif prev&.type == :header && prev.options[:level] == 1 && (text = subtitle_comment_text(el))
|
|
92
|
+
render_subtitle(text)
|
|
93
|
+
@pdf.move_down @config.fetch(:subtitle_spacing_after, 10)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require 'digest/md5'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module Booksmith
|
|
5
|
+
module Images
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def image_child(el)
|
|
9
|
+
non_blank = el.children.reject { |c| c.type == :blank || (c.type == :text && c.value.strip.empty?) }
|
|
10
|
+
return nil unless non_blank.length == 1 && non_blank.first.type == :img
|
|
11
|
+
non_blank.first
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render_image(img_el)
|
|
15
|
+
src = img_el.attr['src']
|
|
16
|
+
path = File.expand_path(src, Dir.pwd)
|
|
17
|
+
return unless File.exist?(path)
|
|
18
|
+
|
|
19
|
+
opts = img_el.attr['title'].to_s
|
|
20
|
+
margin_top = opts[/top=(\d+(?:\.\d+)?)/, 1]&.to_f || 0
|
|
21
|
+
margin_bottom = opts[/bottom=(\d+(?:\.\d+)?)/, 1]&.to_f || 0
|
|
22
|
+
width = opts[/width=(\d+(?:\.\d+)?)/, 1]&.to_f
|
|
23
|
+
flatten_bg = image_flatten_bg_color(bg_override: opts[/bg=([0-9A-Fa-f]{6})/, 1])
|
|
24
|
+
|
|
25
|
+
@pdf.move_down margin_top if margin_top > 0
|
|
26
|
+
max_w = width || @pdf.bounds.width
|
|
27
|
+
max_h = @pdf.cursor - 20
|
|
28
|
+
@pdf.image(prepare_png_path(path, background_color: flatten_bg), fit: [max_w, max_h], position: :center)
|
|
29
|
+
@pdf.move_down margin_bottom if margin_bottom > 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def image_flatten_bg_color(bg_override: nil)
|
|
33
|
+
return bg_override if bg_override
|
|
34
|
+
return @image_flatten_bg if @image_flatten_bg
|
|
35
|
+
|
|
36
|
+
@config.fetch(:image_flatten_bg_color, 'FFFFFF')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def blockquote_image_flatten_color(style_name)
|
|
40
|
+
flatten_key = :"#{style_name}_image_flatten_bg_color"
|
|
41
|
+
return @config[flatten_key] if @config.key?(flatten_key)
|
|
42
|
+
|
|
43
|
+
blockquote_style_value(style_name, :bg_color, 'EEEEEE')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def with_image_flatten_bg(color)
|
|
47
|
+
previous = @image_flatten_bg
|
|
48
|
+
@image_flatten_bg = color
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
@image_flatten_bg = previous
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def magick_available?
|
|
55
|
+
return @magick_available unless @magick_available.nil?
|
|
56
|
+
|
|
57
|
+
@magick_available = system('which magick > /dev/null 2>&1')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Some PNG exports bake a visible checkerboard into opaque pixels instead of
|
|
61
|
+
# using a real alpha channel. Flatten those onto the target background.
|
|
62
|
+
def prepare_png_path(path, background_color:)
|
|
63
|
+
return path unless path.downcase.end_with?('.png')
|
|
64
|
+
return path unless magick_available?
|
|
65
|
+
|
|
66
|
+
bg_hex = background_color.to_s.delete_prefix('#').upcase
|
|
67
|
+
cache_dir = File.join(Dir.pwd, '.booksmith-cache', 'images')
|
|
68
|
+
FileUtils.mkdir_p(cache_dir)
|
|
69
|
+
|
|
70
|
+
cache_name = "#{Digest::MD5.hexdigest(path)}_#{bg_hex}_#{File.mtime(path).to_i}.png"
|
|
71
|
+
cached = File.join(cache_dir, cache_name)
|
|
72
|
+
return cached if File.exist?(cached)
|
|
73
|
+
|
|
74
|
+
success = system(
|
|
75
|
+
'magick', path,
|
|
76
|
+
'-fuzz', '12%',
|
|
77
|
+
'-transparent', 'white',
|
|
78
|
+
'-transparent', '#EBEBEB',
|
|
79
|
+
'-transparent', '#EAEAEA',
|
|
80
|
+
'-background', "##{bg_hex}",
|
|
81
|
+
'-alpha', 'remove',
|
|
82
|
+
'-compose', 'Copy',
|
|
83
|
+
'-flatten',
|
|
84
|
+
cached,
|
|
85
|
+
out: File::NULL,
|
|
86
|
+
err: File::NULL
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
success && File.exist?(cached) ? cached : path
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Booksmith
|
|
2
|
+
module Lists
|
|
3
|
+
private
|
|
4
|
+
|
|
5
|
+
def render_list(el)
|
|
6
|
+
list_indent = list_indent_for(el.type)
|
|
7
|
+
list_bullet_offset = @config.fetch(:list_bullet_offset, 10)
|
|
8
|
+
list_marker_gap = @config.fetch(:list_marker_gap, 4).to_f
|
|
9
|
+
list_item_spacing = @config.fetch(:list_item_spacing, 6)
|
|
10
|
+
list_margin_top = @config.fetch(:list_margin_top, 8)
|
|
11
|
+
list_margin_bottom = @config.fetch(:list_margin_bottom, 8)
|
|
12
|
+
|
|
13
|
+
@list_level += 1
|
|
14
|
+
@pdf.move_down list_margin_top if @list_level == 1
|
|
15
|
+
|
|
16
|
+
el.children.each_with_index do |item, index|
|
|
17
|
+
marker = el.type == :ul ? '•' : "#{index + 1}."
|
|
18
|
+
indent = @list_level * list_indent
|
|
19
|
+
|
|
20
|
+
@pdf.indent(indent) do
|
|
21
|
+
ensure_space_for_list_item
|
|
22
|
+
render_list_marker(marker, list_bullet_offset, align: el.type == :ul ? :center : :right)
|
|
23
|
+
@pdf.indent(list_bullet_offset + list_marker_gap) { convert_element(item) }
|
|
24
|
+
end
|
|
25
|
+
@pdf.move_down list_item_spacing unless index == el.children.length - 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@list_level -= 1
|
|
29
|
+
@pdf.move_down list_margin_bottom if @list_level == 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render_list_item(el)
|
|
33
|
+
el.children.each { |child| convert_element(child) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def list_indent_for(list_type)
|
|
37
|
+
key = list_type == :ul ? :list_bullet_indent : :list_number_indent
|
|
38
|
+
@config.fetch(key, @config.fetch(:list_indent, 18)).to_f
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ensure_space_for_list_item
|
|
42
|
+
font_size = @config[:font_size].to_f
|
|
43
|
+
min_height = font_size * @config[:line_height].to_f
|
|
44
|
+
@pdf.start_new_page if @pdf.cursor <= @pdf.bounds.bottom + min_height
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def render_list_marker(marker, offset, align: :right)
|
|
48
|
+
font_size = @config[:font_size].to_f
|
|
49
|
+
body_font = @config.fetch(:body_font, 'Noto Sans')
|
|
50
|
+
|
|
51
|
+
@pdf.font(body_font, size: font_size) do
|
|
52
|
+
@pdf.formatted_text_box(
|
|
53
|
+
[{ text: marker, size: font_size, font: body_font }],
|
|
54
|
+
at: [@pdf.bounds.left, @pdf.cursor],
|
|
55
|
+
width: offset,
|
|
56
|
+
align: align,
|
|
57
|
+
leading: @config[:line_height]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Booksmith
|
|
2
|
+
module Text
|
|
3
|
+
SMART_QUOTES = {
|
|
4
|
+
ldquo: "\u201c", rdquo: "\u201d",
|
|
5
|
+
lsquo: "\u2018", rsquo: "\u2019",
|
|
6
|
+
mdash: "\u2014", ndash: "\u2013",
|
|
7
|
+
laquo: "\u00ab", raquo: "\u00bb"
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def extract_text(element, styles: nil)
|
|
13
|
+
formatted_text = []
|
|
14
|
+
|
|
15
|
+
element.children.each do |child|
|
|
16
|
+
case child.type
|
|
17
|
+
when :text
|
|
18
|
+
entry = { text: child.value }
|
|
19
|
+
entry[:styles] = styles if styles && !styles.empty?
|
|
20
|
+
formatted_text << entry
|
|
21
|
+
when :strong
|
|
22
|
+
new_styles = (styles || []) + [:bold]
|
|
23
|
+
formatted_text.concat(extract_text(child, styles: new_styles))
|
|
24
|
+
when :em
|
|
25
|
+
new_styles = (styles || []) + [:italic]
|
|
26
|
+
formatted_text.concat(extract_text(child, styles: new_styles))
|
|
27
|
+
when :codespan
|
|
28
|
+
entry = { text: child.value, font: 'Noto Sans Mono', size: @config[:font_size] - 2 }
|
|
29
|
+
entry[:styles] = styles if styles && !styles.empty?
|
|
30
|
+
formatted_text << entry
|
|
31
|
+
when :smart_quote
|
|
32
|
+
char = SMART_QUOTES[child.value] || '?'
|
|
33
|
+
entry = { text: char }
|
|
34
|
+
entry[:styles] = styles if styles && !styles.empty?
|
|
35
|
+
formatted_text << entry
|
|
36
|
+
when :a
|
|
37
|
+
new_styles = (styles || []) + [:underline]
|
|
38
|
+
extract_text(child, styles: new_styles).each do |entry|
|
|
39
|
+
entry[:color] = '0000FF'
|
|
40
|
+
formatted_text << entry
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
# Ignore unsupported inline types for now
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
formatted_text
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def plain_text(element)
|
|
51
|
+
return '' unless element
|
|
52
|
+
|
|
53
|
+
extract_text(element).map { |entry| entry[:text] }.join
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/booksmith.rb
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require 'commander'
|
|
2
|
+
require 'prawn'
|
|
3
|
+
require 'kramdown'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
require_relative 'booksmith/version'
|
|
7
|
+
require_relative 'booksmith/font_paths'
|
|
8
|
+
require_relative 'booksmith/headers'
|
|
9
|
+
require_relative 'booksmith/text'
|
|
10
|
+
require_relative 'booksmith/lists'
|
|
11
|
+
require_relative 'booksmith/blockquotes'
|
|
12
|
+
require_relative 'booksmith/images'
|
|
13
|
+
require_relative 'booksmith/book_generator'
|
|
14
|
+
require_relative 'booksmith/epub_generator'
|
|
15
|
+
|
|
16
|
+
module Booksmith
|
|
17
|
+
class PDFConverter
|
|
18
|
+
include Headers
|
|
19
|
+
include Text
|
|
20
|
+
include Lists
|
|
21
|
+
include Blockquotes
|
|
22
|
+
include Images
|
|
23
|
+
|
|
24
|
+
def initialize(pdf, config, image_flatten_bg: nil)
|
|
25
|
+
@pdf = pdf
|
|
26
|
+
@config = config
|
|
27
|
+
@list_level = 0
|
|
28
|
+
@image_flatten_bg = image_flatten_bg
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def convert(content)
|
|
32
|
+
content = normalize_consecutive_headers(content)
|
|
33
|
+
doc = Kramdown::Document.new(content)
|
|
34
|
+
convert_element(doc.root)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def convert_element(el, prev: nil, next_el: nil)
|
|
40
|
+
case el.type
|
|
41
|
+
when :header then render_header(el, prev: prev, next_el: next_el)
|
|
42
|
+
when :xml_comment then render_xml_comment(el, prev: prev)
|
|
43
|
+
when :p then render_paragraph(el)
|
|
44
|
+
when :blockquote then render_blockquote_element(el)
|
|
45
|
+
when :codeblock then render_codeblock(el)
|
|
46
|
+
when :ul, :ol then render_list(el)
|
|
47
|
+
when :li then render_list_item(el)
|
|
48
|
+
when :text then nil
|
|
49
|
+
else render_children(el)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_paragraph(el)
|
|
54
|
+
img_el = image_child(el)
|
|
55
|
+
if img_el
|
|
56
|
+
render_image(img_el)
|
|
57
|
+
@pdf.move_down 10
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@pdf.formatted_text extract_text(el), size: @config[:font_size], leading: @config[:line_height]
|
|
62
|
+
@pdf.move_down 10 unless @list_level > 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_blockquote_element(el)
|
|
66
|
+
if exercise_blockquote?(el)
|
|
67
|
+
render_exercise_blockquote(el)
|
|
68
|
+
elsif alt_blockquote?(el)
|
|
69
|
+
render_blockquote(el, :alt_blockquote)
|
|
70
|
+
else
|
|
71
|
+
render_blockquote(el, :blockquote)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_codeblock(el)
|
|
76
|
+
@pdf.font('Noto Sans Mono') do
|
|
77
|
+
@pdf.formatted_text_box(
|
|
78
|
+
[{ text: el.value, size: @config[:font_size] - 2 }],
|
|
79
|
+
at: [@pdf.bounds.left + 20, @pdf.cursor],
|
|
80
|
+
width: @pdf.bounds.width - 40
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
@pdf.move_down(el.value.lines.count * (@config[:font_size] + 2) + 10)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_children(el)
|
|
87
|
+
el.children.each_with_index do |child, idx|
|
|
88
|
+
prev_idx = idx - 1
|
|
89
|
+
prev_child = nil
|
|
90
|
+
while prev_idx >= 0
|
|
91
|
+
candidate = el.children[prev_idx]
|
|
92
|
+
if candidate.type != :blank
|
|
93
|
+
prev_child = candidate
|
|
94
|
+
break
|
|
95
|
+
end
|
|
96
|
+
prev_idx -= 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
next_idx = idx + 1
|
|
100
|
+
next_child = nil
|
|
101
|
+
while next_idx < el.children.length
|
|
102
|
+
candidate = el.children[next_idx]
|
|
103
|
+
if candidate.type != :blank
|
|
104
|
+
next_child = candidate
|
|
105
|
+
break
|
|
106
|
+
end
|
|
107
|
+
next_idx += 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
convert_element(child, prev: prev_child, next_el: next_child)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class CLI
|
|
116
|
+
include Commander::Methods
|
|
117
|
+
|
|
118
|
+
def self.run!
|
|
119
|
+
new.run!
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def run!
|
|
123
|
+
program :name, 'Booksmith'
|
|
124
|
+
program :version, Booksmith::VERSION
|
|
125
|
+
program :description, 'A flexible book publishing tool'
|
|
126
|
+
|
|
127
|
+
default_command :help
|
|
128
|
+
|
|
129
|
+
command :init do |c|
|
|
130
|
+
c.syntax = 'booksmith init'
|
|
131
|
+
c.description = 'Initialize a new book project'
|
|
132
|
+
c.action do |_args, _options|
|
|
133
|
+
CLI.create_project_structure
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
command :build do |c|
|
|
138
|
+
c.syntax = 'booksmith build [options]'
|
|
139
|
+
c.description = 'Generate the book in PDF and/or EPUB format'
|
|
140
|
+
c.option '--no-cover', 'Skip cover page generation'
|
|
141
|
+
c.option '--epub', 'Generate EPUB format'
|
|
142
|
+
c.option '--pdf', 'Generate PDF format'
|
|
143
|
+
c.option '--format FORMAT', String, 'Specify format to build (pdf, epub, or all)'
|
|
144
|
+
c.action do |_args, options|
|
|
145
|
+
formats = CLI.determine_formats(options)
|
|
146
|
+
BookGenerator.new(options).generate if formats.include?(:pdf)
|
|
147
|
+
EpubGenerator.new(options).generate if formats.include?(:epub)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
command :fonts do |c|
|
|
152
|
+
c.syntax = 'booksmith fonts'
|
|
153
|
+
c.description = 'List registered Prawn font families'
|
|
154
|
+
c.action do |_args, _options|
|
|
155
|
+
CLI.list_fonts
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
super
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.create_project_structure
|
|
163
|
+
dirs = %w[assets chapters config]
|
|
164
|
+
dirs.each { |dir| FileUtils.mkdir_p(dir) }
|
|
165
|
+
|
|
166
|
+
templates_dir = File.expand_path('templates', __dir__)
|
|
167
|
+
|
|
168
|
+
config_template = File.join(templates_dir, 'book.yml')
|
|
169
|
+
chapter_template = File.join(templates_dir, '01_introduction.md')
|
|
170
|
+
clock_template = File.join(templates_dir, 'clock.png')
|
|
171
|
+
|
|
172
|
+
FileUtils.cp(config_template, 'config/book.yml') if File.exist?(config_template)
|
|
173
|
+
FileUtils.cp(chapter_template, 'chapters/01_introduction.md') if File.exist?(chapter_template)
|
|
174
|
+
FileUtils.cp(clock_template, 'assets/clock.png') if File.exist?(clock_template)
|
|
175
|
+
|
|
176
|
+
puts '✅ Book project initialized!'
|
|
177
|
+
puts '📁 Created assets/, chapters/, and config/ directories'
|
|
178
|
+
puts '📝 Generated config/book.yml'
|
|
179
|
+
puts '📚 Generated sample chapter chapters/01_introduction.md'
|
|
180
|
+
puts '🖼️ Added default exercise icon assets/clock.png'
|
|
181
|
+
puts ''
|
|
182
|
+
puts 'Next steps:'
|
|
183
|
+
puts ' booksmith build # generates book.pdf'
|
|
184
|
+
puts ' booksmith build --epub # generates book.epub'
|
|
185
|
+
puts ' booksmith build --format all'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.determine_formats(options)
|
|
189
|
+
if options.format
|
|
190
|
+
case options.format.to_s.downcase
|
|
191
|
+
when 'all', 'both'
|
|
192
|
+
[:pdf, :epub]
|
|
193
|
+
when 'epub'
|
|
194
|
+
[:epub]
|
|
195
|
+
when 'pdf'
|
|
196
|
+
[:pdf]
|
|
197
|
+
else
|
|
198
|
+
raise ArgumentError, "Unknown format: #{options.format}. Supported formats are: pdf, epub, all"
|
|
199
|
+
end
|
|
200
|
+
elsif options.epub && options.pdf
|
|
201
|
+
[:pdf, :epub]
|
|
202
|
+
elsif options.epub
|
|
203
|
+
[:epub]
|
|
204
|
+
elsif options.pdf
|
|
205
|
+
[:pdf]
|
|
206
|
+
else
|
|
207
|
+
[:pdf]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.list_fonts
|
|
212
|
+
pdf = Prawn::Document.new
|
|
213
|
+
FontPaths.register(pdf)
|
|
214
|
+
puts 'Registered Prawn font families:'
|
|
215
|
+
pdf.font_families.keys.sort.each { |name| puts "- #{name}" }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# CHAPTER I
|
|
2
|
+
<!-- subtitle: GETTING STARTED WITH BOOKSMITH -->
|
|
3
|
+
|
|
4
|
+
Welcome to your new book! This is a sample chapter generated by **Booksmith**.
|
|
5
|
+
|
|
6
|
+
Booksmith converts Markdown chapters into beautifully formatted PDF and EPUB publications using Prawn, GEPUB, and Kramdown.
|
|
7
|
+
|
|
8
|
+
## Features Overview
|
|
9
|
+
|
|
10
|
+
Booksmith provides a rich set of layout and typography enhancements:
|
|
11
|
+
|
|
12
|
+
- **Configurable typography**: Fonts, sizes, and leading customizable via `config/book.yml`
|
|
13
|
+
- **Subtitle comments**: Add subtitles right under chapter titles without breaking Markdown linters
|
|
14
|
+
- **Specialized callouts**: Standard, alternate, and exercise blockquotes
|
|
15
|
+
- **Smart page breaks**: Invisible page break markers for precise print layout
|
|
16
|
+
|
|
17
|
+
<!-- PAGE_BREAK -->
|
|
18
|
+
|
|
19
|
+
## Markdown Examples
|
|
20
|
+
|
|
21
|
+
### Lists and Code Blocks
|
|
22
|
+
|
|
23
|
+
You can write nested bullet lists and numbered lists:
|
|
24
|
+
|
|
25
|
+
1. First step in setting up your book:
|
|
26
|
+
- Edit `config/book.yml` to set your book title and author
|
|
27
|
+
- Place chapter Markdown files in the `chapters/` folder
|
|
28
|
+
2. Run `booksmith build` to generate `book.pdf`
|
|
29
|
+
3. Run `booksmith build --epub` to generate `book.epub`
|
|
30
|
+
|
|
31
|
+
Here is an example code block:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
def publish_book
|
|
35
|
+
puts "Building your masterpiece with Booksmith..."
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Callouts and Blockquotes
|
|
40
|
+
|
|
41
|
+
Booksmith supports three distinct blockquote styles.
|
|
42
|
+
|
|
43
|
+
> **Standard Callout**
|
|
44
|
+
>
|
|
45
|
+
> When the first line is wrapped in bold text, Booksmith renders it as a distinct header row inside the shaded callout box.
|
|
46
|
+
|
|
47
|
+
> **Alternate Callout**
|
|
48
|
+
>
|
|
49
|
+
> Use `{: .alt_blockquote}` for secondary notes, sidebars, or slightly different background tinting.
|
|
50
|
+
{: .alt_blockquote}
|
|
51
|
+
|
|
52
|
+
> Try It Yourself
|
|
53
|
+
>
|
|
54
|
+
> Exercise callouts feature an icon (configured in `config/book.yml`) and are styled for hands-on activities:
|
|
55
|
+
>
|
|
56
|
+
> - What is the main thesis of your book?
|
|
57
|
+
> - Who is the intended reader?
|
|
58
|
+
{: .exercise_blockquote}
|