przn 0.1.5
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.txt +21 -0
- data/README.md +156 -0
- data/Rakefile +12 -0
- data/default_theme.yml +16 -0
- data/exe/przn +44 -0
- data/lib/przn/controller.rb +73 -0
- data/lib/przn/image_util.rb +85 -0
- data/lib/przn/kitty_text.rb +27 -0
- data/lib/przn/parser.rb +266 -0
- data/lib/przn/pdf_exporter.rb +546 -0
- data/lib/przn/presentation.rb +37 -0
- data/lib/przn/renderer.rb +611 -0
- data/lib/przn/slide.rb +11 -0
- data/lib/przn/terminal.rb +72 -0
- data/lib/przn/theme.rb +41 -0
- data/lib/przn/version.rb +5 -0
- data/lib/przn.rb +34 -0
- data/sample/sample.md +45 -0
- data/sig/przn.rbs +4 -0
- metadata +78 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Patch Prawn's line wrapping to break at CJK character boundaries.
|
|
4
|
+
# Prawn's default scan_pattern only breaks at spaces and hyphens,
|
|
5
|
+
# which prevents wrapping for languages like Japanese that have no word spaces.
|
|
6
|
+
module PrawnCJKLineWrap
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
CJK_CHARS = "\u3000-\u9FFF\uF900-\uFAFF\uFF01-\uFF60"
|
|
10
|
+
|
|
11
|
+
def scan_pattern(encoding = ::Encoding::UTF_8)
|
|
12
|
+
ebc = break_chars(encoding)
|
|
13
|
+
eshy = soft_hyphen(encoding)
|
|
14
|
+
ehy = hyphen(encoding)
|
|
15
|
+
ews = whitespace(encoding)
|
|
16
|
+
|
|
17
|
+
patterns = [
|
|
18
|
+
"[^#{CJK_CHARS}#{ebc}]+#{eshy}",
|
|
19
|
+
"[^#{CJK_CHARS}#{ebc}]+#{ehy}+",
|
|
20
|
+
"[^#{CJK_CHARS}#{ebc}]+",
|
|
21
|
+
"[#{CJK_CHARS}]",
|
|
22
|
+
"[#{ews}]+",
|
|
23
|
+
"#{ehy}+[^#{ebc}]*",
|
|
24
|
+
eshy.to_s,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
pattern = patterns
|
|
28
|
+
.map { |p| p.encode(encoding) }
|
|
29
|
+
.join('|')
|
|
30
|
+
|
|
31
|
+
Regexp.new(pattern)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module Przn
|
|
36
|
+
class PdfExporter
|
|
37
|
+
PAGE_WIDTH = 960
|
|
38
|
+
PAGE_HEIGHT = 540
|
|
39
|
+
DEFAULT_FONT_SIZE = 18
|
|
40
|
+
DEFAULT_SCALE_TO_PT = {
|
|
41
|
+
1 => 10, 2 => 18, 3 => 24, 4 => 32,
|
|
42
|
+
5 => 40, 6 => 48, 7 => 56,
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
DEFAULT_SCALE = Renderer::DEFAULT_SCALE
|
|
46
|
+
|
|
47
|
+
COLOR_MAP = {
|
|
48
|
+
'red' => 'FF5555', 'green' => '50FA7B', 'yellow' => 'F1FA8C', 'blue' => '6272A4',
|
|
49
|
+
'magenta' => 'FF79C6', 'cyan' => '8BE9FD', 'white' => 'F8F8F2',
|
|
50
|
+
'bright_red' => 'FF6E6E', 'bright_green' => '69FF94', 'bright_yellow' => 'FFFFA5',
|
|
51
|
+
'bright_blue' => 'D6ACFF', 'bright_magenta' => 'FF92DF', 'bright_cyan' => 'A4FFFF',
|
|
52
|
+
'bright_white' => 'FFFFFF',
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
def initialize(presentation, base_dir: '.', theme: nil)
|
|
56
|
+
@presentation = presentation
|
|
57
|
+
@base_dir = base_dir
|
|
58
|
+
@theme = theme || Theme.default
|
|
59
|
+
@bg_color = @theme.colors[:background]
|
|
60
|
+
@fg_color = @theme.colors[:foreground]
|
|
61
|
+
@code_bg = @theme.colors[:code_bg]
|
|
62
|
+
@dim_color = @theme.colors[:dim]
|
|
63
|
+
@inline_code_color = @theme.colors[:inline_code]
|
|
64
|
+
@heading_color = @theme.colors[:heading] || @fg_color
|
|
65
|
+
base = (@theme.font[:size] || DEFAULT_FONT_SIZE).to_f
|
|
66
|
+
ratio = base / DEFAULT_FONT_SIZE
|
|
67
|
+
@scale_to_pt = DEFAULT_SCALE_TO_PT.transform_values { |v| v * ratio }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Fallback font paths when fc-match is not available.
|
|
71
|
+
# Prawn's ttfunk requires TrueType outlines (glyf table), not CFF-based fonts.
|
|
72
|
+
FONT_SEARCH_PATHS = [
|
|
73
|
+
-> { File.join(Dir.home, 'Library/Fonts/NotoSansJP-Regular.ttf') },
|
|
74
|
+
-> { Dir.glob('/usr/share/fonts/**/NotoSansCJK-Regular.ttc').first },
|
|
75
|
+
-> { Dir.glob('/usr/share/fonts/**/NotoSansJP-Regular.ttf').first },
|
|
76
|
+
-> { File.join(Dir.home, 'Library/Fonts/HackGen-Regular.ttf') },
|
|
77
|
+
-> { '/Library/Fonts/Arial Unicode.ttf' },
|
|
78
|
+
-> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' },
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
FALLBACK_FONT_FAMILIES = %w[NotoSansCJK NotoSansJP HackGen].freeze
|
|
82
|
+
|
|
83
|
+
def export(output_path)
|
|
84
|
+
require 'prawn'
|
|
85
|
+
Prawn::Text::Formatted::LineWrap.prepend(PrawnCJKLineWrap) unless Prawn::Text::Formatted::LineWrap < PrawnCJKLineWrap
|
|
86
|
+
|
|
87
|
+
pdf = Prawn::Document.new(
|
|
88
|
+
page_size: [PAGE_WIDTH, PAGE_HEIGHT],
|
|
89
|
+
margin: 0,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
register_fonts(pdf)
|
|
93
|
+
|
|
94
|
+
@presentation.slides.each_with_index do |slide, si|
|
|
95
|
+
pdf.start_new_page unless si == 0
|
|
96
|
+
render_slide(pdf, slide, si)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
pdf.render_file(output_path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def register_fonts(pdf)
|
|
105
|
+
font_path, family = find_font
|
|
106
|
+
return unless font_path
|
|
107
|
+
|
|
108
|
+
if font_path.end_with?('.ttc')
|
|
109
|
+
pdf.font_families.update(
|
|
110
|
+
'CJK' => {
|
|
111
|
+
normal: {file: font_path, font: 0},
|
|
112
|
+
bold: {file: font_path.sub('W3', 'W6').then { |p| File.exist?(p) ? p : font_path }, font: 0},
|
|
113
|
+
italic: {file: font_path, font: 0},
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
else
|
|
117
|
+
bold_path = find_bold_font(font_path, family)
|
|
118
|
+
pdf.font_families.update(
|
|
119
|
+
'CJK' => {
|
|
120
|
+
normal: font_path,
|
|
121
|
+
bold: bold_path,
|
|
122
|
+
italic: font_path,
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
pdf.font 'CJK'
|
|
128
|
+
@font_registered = true
|
|
129
|
+
|
|
130
|
+
register_emoji_fallback(pdf, font_path)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_font
|
|
134
|
+
family = @theme.font[:family]
|
|
135
|
+
|
|
136
|
+
if family
|
|
137
|
+
path = fc_find(family)
|
|
138
|
+
return [path, family] if path
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
FALLBACK_FONT_FAMILIES.each do |name|
|
|
142
|
+
path = fc_find(name)
|
|
143
|
+
return [path, name] if path
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
FONT_SEARCH_PATHS.each do |finder|
|
|
147
|
+
path = finder.call
|
|
148
|
+
return [path, nil] if path && File.exist?(path)
|
|
149
|
+
end
|
|
150
|
+
[nil, nil]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def find_bold_font(normal_path, family)
|
|
154
|
+
if family
|
|
155
|
+
path = fc_find(family, style: 'Bold')
|
|
156
|
+
return path if path
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
bold_path = normal_path.sub(/Regular|Medium/, 'Bold').sub(/-[^-]*\./, '-Bold.')
|
|
160
|
+
File.exist?(bold_path) ? bold_path : normal_path
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def register_emoji_fallback(pdf, primary_font_path)
|
|
164
|
+
emoji_path = find_emoji_font
|
|
165
|
+
return unless emoji_path
|
|
166
|
+
return if emoji_path == primary_font_path
|
|
167
|
+
|
|
168
|
+
pdf.font_families.update(
|
|
169
|
+
'Emoji' => { normal: emoji_path, bold: emoji_path, italic: emoji_path }
|
|
170
|
+
)
|
|
171
|
+
pdf.fallback_fonts = ['Emoji']
|
|
172
|
+
rescue
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
EMOJI_FONT_FAMILIES = ['Noto Emoji'].freeze
|
|
177
|
+
|
|
178
|
+
def find_emoji_font
|
|
179
|
+
EMOJI_FONT_FAMILIES.each do |name|
|
|
180
|
+
path = fc_find(name)
|
|
181
|
+
next unless path
|
|
182
|
+
# Only use fonts with glyf outlines (not SBIX/COLR bitmap-only fonts)
|
|
183
|
+
ttf = TTFunk::File.open(path)
|
|
184
|
+
return path if ttf.directory.tables.key?('glyf')
|
|
185
|
+
rescue
|
|
186
|
+
next
|
|
187
|
+
end
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Find a font file by family name using fc-list.
|
|
192
|
+
# fc-list does exact family matching (unlike fc-match which always returns something).
|
|
193
|
+
# We parse fc-list output directly to get the file path, preferring the requested style.
|
|
194
|
+
def fc_find(family, style: nil)
|
|
195
|
+
output = IO.popen(['fc-list', family, '--format=%{file}\n'], &:read)&.strip
|
|
196
|
+
return nil if output.nil? || output.empty?
|
|
197
|
+
|
|
198
|
+
paths = output.lines.map(&:strip).uniq.select { |p| p.end_with?('.ttf', '.ttc') && File.exist?(p) }
|
|
199
|
+
return nil if paths.empty?
|
|
200
|
+
|
|
201
|
+
# Prefer the path whose filename matches the requested style (default: Regular)
|
|
202
|
+
keyword = style || 'Regular'
|
|
203
|
+
paths.find { |p| File.basename(p) =~ /[-_]#{keyword}\b/i } || paths.first
|
|
204
|
+
rescue Errno::ENOENT
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def render_slide(pdf, slide, slide_index)
|
|
209
|
+
draw_background(pdf)
|
|
210
|
+
|
|
211
|
+
margin_x = PAGE_WIDTH / 16.0
|
|
212
|
+
content_width = PAGE_WIDTH - margin_x * 2
|
|
213
|
+
|
|
214
|
+
if slide_index == 0
|
|
215
|
+
# Title slide: vertically center
|
|
216
|
+
total_h = estimate_slide_height(slide, content_width, pdf)
|
|
217
|
+
y = (PAGE_HEIGHT + total_h) / 2.0
|
|
218
|
+
else
|
|
219
|
+
y = PAGE_HEIGHT - 20
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
pending_align = nil
|
|
223
|
+
slide.blocks.each do |block|
|
|
224
|
+
if block[:type] == :align
|
|
225
|
+
pending_align = block[:align]
|
|
226
|
+
else
|
|
227
|
+
y = render_block(pdf, block, margin_x, content_width, y, align: pending_align)
|
|
228
|
+
pending_align = nil
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Page number
|
|
233
|
+
total = @presentation.total
|
|
234
|
+
status = "#{slide_index + 1} / #{total}"
|
|
235
|
+
pdf.fill_color @dim_color
|
|
236
|
+
pdf.text_box status, at: [0, 16], width: PAGE_WIDTH - 10, height: 14, size: 8, align: :right
|
|
237
|
+
pdf.fill_color @fg_color
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def draw_background(pdf)
|
|
241
|
+
pdf.canvas do
|
|
242
|
+
pdf.fill_color @bg_color
|
|
243
|
+
pdf.fill_rectangle [0, PAGE_HEIGHT], PAGE_WIDTH, PAGE_HEIGHT
|
|
244
|
+
end
|
|
245
|
+
pdf.fill_color @fg_color
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def render_block(pdf, block, margin_x, content_width, y, align: nil)
|
|
249
|
+
case block[:type]
|
|
250
|
+
when :heading then render_heading(pdf, block, margin_x, content_width, y)
|
|
251
|
+
when :paragraph then render_paragraph(pdf, block, margin_x, content_width, y, align: align)
|
|
252
|
+
when :code_block then render_code_block(pdf, block, margin_x, content_width, y)
|
|
253
|
+
when :unordered_list then render_unordered_list(pdf, block, margin_x, content_width, y)
|
|
254
|
+
when :ordered_list then render_ordered_list(pdf, block, margin_x, content_width, y)
|
|
255
|
+
when :definition_list then render_definition_list(pdf, block, margin_x, content_width, y)
|
|
256
|
+
when :blockquote then render_blockquote(pdf, block, margin_x, content_width, y)
|
|
257
|
+
when :table then render_table(pdf, block, margin_x, content_width, y)
|
|
258
|
+
when :image then render_image(pdf, block, margin_x, content_width, y)
|
|
259
|
+
when :blank then y - @scale_to_pt[DEFAULT_SCALE]
|
|
260
|
+
else y - @scale_to_pt[DEFAULT_SCALE]
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def render_heading(pdf, block, margin_x, content_width, y)
|
|
265
|
+
text = block[:content]
|
|
266
|
+
if block[:level] == 1
|
|
267
|
+
scale = KittyText::HEADING_SCALES[1]
|
|
268
|
+
pt = @scale_to_pt[scale]
|
|
269
|
+
formatted = build_formatted_text(text, pt).map { |f| f.merge(styles: (f[:styles] || []) | [:bold]) }
|
|
270
|
+
h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width, align: :center)
|
|
271
|
+
y - h - heading_margin(pt)
|
|
272
|
+
else
|
|
273
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
274
|
+
prefix = [{text: bullet, size: pt, color: @heading_color, styles: [:bold]}]
|
|
275
|
+
formatted = prefix + build_formatted_text(text, pt)
|
|
276
|
+
h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width)
|
|
277
|
+
y - h - 4
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def render_paragraph(pdf, block, margin_x, content_width, y, align: nil)
|
|
282
|
+
text = block[:content]
|
|
283
|
+
scale = max_inline_scale(text) || DEFAULT_SCALE
|
|
284
|
+
pt = @scale_to_pt[scale]
|
|
285
|
+
formatted = build_formatted_text(text, pt)
|
|
286
|
+
align_sym = align || :left
|
|
287
|
+
|
|
288
|
+
h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width, align: align_sym)
|
|
289
|
+
y - h - 2
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def render_code_block(pdf, block, margin_x, content_width, y)
|
|
293
|
+
code_lines = block[:content].lines.map(&:chomp)
|
|
294
|
+
return y - @scale_to_pt[DEFAULT_SCALE] if code_lines.empty?
|
|
295
|
+
|
|
296
|
+
pt = @scale_to_pt[DEFAULT_SCALE] * 0.7
|
|
297
|
+
line_height = pt * 1.4
|
|
298
|
+
padding = 8
|
|
299
|
+
box_height = code_lines.size * line_height + padding * 2
|
|
300
|
+
|
|
301
|
+
# Draw background
|
|
302
|
+
pdf.fill_color @code_bg
|
|
303
|
+
pdf.fill_rounded_rectangle [margin_x, y], content_width, box_height, 4
|
|
304
|
+
pdf.fill_color @fg_color
|
|
305
|
+
|
|
306
|
+
code_y = y - padding
|
|
307
|
+
code_lines.each do |line|
|
|
308
|
+
# Replace leading spaces with non-breaking spaces to preserve indentation
|
|
309
|
+
preserved = line.sub(/\A +/) { |m| "\u00A0" * m.length }
|
|
310
|
+
pdf.text_box preserved, at: [margin_x + padding, code_y], width: content_width - padding * 2, height: line_height, size: pt, color: @fg_color, overflow: :shrink_to_fit
|
|
311
|
+
code_y -= line_height
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
y - box_height - 6
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def render_unordered_list(pdf, block, margin_x, content_width, y)
|
|
318
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
319
|
+
block[:items].each do |item|
|
|
320
|
+
depth = item[:depth] || 0
|
|
321
|
+
indent = depth * pt
|
|
322
|
+
prefix = [{text: bullet, size: pt, color: @fg_color}]
|
|
323
|
+
formatted = prefix + build_formatted_text(item[:text], pt)
|
|
324
|
+
h = render_formatted(pdf, formatted, at: [margin_x + indent, y], width: content_width - indent)
|
|
325
|
+
y -= h + 6
|
|
326
|
+
end
|
|
327
|
+
y
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def render_ordered_list(pdf, block, margin_x, content_width, y)
|
|
331
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
332
|
+
block[:items].each_with_index do |item, i|
|
|
333
|
+
depth = item[:depth] || 0
|
|
334
|
+
indent = depth * pt
|
|
335
|
+
prefix = [{text: "#{i + 1}. ", size: pt, color: @fg_color}]
|
|
336
|
+
formatted = prefix + build_formatted_text(item[:text], pt)
|
|
337
|
+
h = render_formatted(pdf, formatted, at: [margin_x + indent, y], width: content_width - indent)
|
|
338
|
+
y -= h + 6
|
|
339
|
+
end
|
|
340
|
+
y
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def render_definition_list(pdf, block, margin_x, content_width, y)
|
|
344
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
345
|
+
|
|
346
|
+
# Term (bold)
|
|
347
|
+
formatted = build_formatted_text(block[:term], pt).map { |f| f.merge(styles: (f[:styles] || []) | [:bold]) }
|
|
348
|
+
h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width)
|
|
349
|
+
y -= h + 2
|
|
350
|
+
|
|
351
|
+
# Definition (indented)
|
|
352
|
+
indent = pt * 1.5
|
|
353
|
+
block[:definition].each_line do |line|
|
|
354
|
+
formatted = build_formatted_text(line.chomp, pt)
|
|
355
|
+
h = render_formatted(pdf, formatted, at: [margin_x + indent, y], width: content_width - indent)
|
|
356
|
+
y -= h + 2
|
|
357
|
+
end
|
|
358
|
+
y - 4
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def render_blockquote(pdf, block, margin_x, content_width, y)
|
|
362
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
363
|
+
indent = pt
|
|
364
|
+
|
|
365
|
+
block[:content].each_line do |line|
|
|
366
|
+
formatted = build_formatted_text(line.chomp, pt).map { |f| f.merge(color: @dim_color) }
|
|
367
|
+
h = render_formatted(pdf, formatted, at: [margin_x + indent, y], width: content_width - indent)
|
|
368
|
+
|
|
369
|
+
# Draw pipe
|
|
370
|
+
pdf.fill_color @dim_color
|
|
371
|
+
pdf.fill_rectangle [margin_x, y], 2, h
|
|
372
|
+
pdf.fill_color @fg_color
|
|
373
|
+
|
|
374
|
+
y -= h + 2
|
|
375
|
+
end
|
|
376
|
+
y - 4
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def render_table(pdf, block, margin_x, content_width, y)
|
|
380
|
+
pt = @scale_to_pt[DEFAULT_SCALE] * 0.8
|
|
381
|
+
row_height = pt * 1.6
|
|
382
|
+
all_rows = [block[:header]] + block[:rows]
|
|
383
|
+
num_cols = block[:header]&.size || 0
|
|
384
|
+
return y if num_cols == 0
|
|
385
|
+
|
|
386
|
+
col_width = content_width / num_cols.to_f
|
|
387
|
+
|
|
388
|
+
all_rows.each_with_index do |cells, ri|
|
|
389
|
+
next unless cells
|
|
390
|
+
|
|
391
|
+
cells.each_with_index do |cell, ci|
|
|
392
|
+
x = margin_x + ci * col_width
|
|
393
|
+
styles = ri == 0 ? [:bold] : []
|
|
394
|
+
pdf.formatted_text_box [{text: cell, size: pt, color: @fg_color, styles: styles}],
|
|
395
|
+
at: [x + 4, y], width: col_width - 8, height: row_height, overflow: :shrink_to_fit
|
|
396
|
+
end
|
|
397
|
+
y -= row_height
|
|
398
|
+
|
|
399
|
+
# Separator after header
|
|
400
|
+
if ri == 0
|
|
401
|
+
pdf.stroke_color @dim_color
|
|
402
|
+
pdf.line_width 0.5
|
|
403
|
+
pdf.stroke_horizontal_line margin_x, margin_x + content_width, at: y + row_height * 0.3
|
|
404
|
+
pdf.stroke_color @fg_color
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
y - 4
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def render_image(pdf, block, margin_x, content_width, y)
|
|
411
|
+
path = resolve_image_path(block[:path])
|
|
412
|
+
return y - @scale_to_pt[DEFAULT_SCALE] unless File.exist?(path)
|
|
413
|
+
|
|
414
|
+
begin
|
|
415
|
+
max_h = PAGE_HEIGHT * 0.6
|
|
416
|
+
if (rh = block[:attrs]['relative_height'])
|
|
417
|
+
max_h = PAGE_HEIGHT * rh.to_i / 100.0
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
img_size = ImageUtil.image_size(path)
|
|
421
|
+
return y - @scale_to_pt[DEFAULT_SCALE] unless img_size
|
|
422
|
+
|
|
423
|
+
img_w, img_h = img_size
|
|
424
|
+
scale = [content_width / img_w.to_f, max_h / img_h.to_f, 1.0].min
|
|
425
|
+
display_w = img_w * scale
|
|
426
|
+
display_h = img_h * scale
|
|
427
|
+
img_x = margin_x + (content_width - display_w) / 2.0
|
|
428
|
+
|
|
429
|
+
pdf.image path, fit: [content_width, max_h], at: [img_x, y]
|
|
430
|
+
y - display_h - 6
|
|
431
|
+
rescue Prawn::Errors::UnsupportedImageType
|
|
432
|
+
y - @scale_to_pt[DEFAULT_SCALE]
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def resolve_image_path(path)
|
|
437
|
+
return path if File.absolute_path?(path) == path
|
|
438
|
+
File.expand_path(path, @base_dir)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def build_formatted_text(text, default_pt)
|
|
442
|
+
segments = Parser.parse_inline(text)
|
|
443
|
+
segments.map { |segment|
|
|
444
|
+
type = segment[0]
|
|
445
|
+
content = segment[1]
|
|
446
|
+
|
|
447
|
+
case type
|
|
448
|
+
when :tag
|
|
449
|
+
tag_name = segment[2]
|
|
450
|
+
if (scale = Parser::SIZE_SCALES[tag_name])
|
|
451
|
+
{text: content, size: @scale_to_pt[scale], color: @fg_color}
|
|
452
|
+
elsif (hex = COLOR_MAP[tag_name])
|
|
453
|
+
{text: content, size: default_pt, color: hex}
|
|
454
|
+
elsif tag_name.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
455
|
+
{text: content, size: default_pt, color: tag_name.upcase}
|
|
456
|
+
else
|
|
457
|
+
{text: content, size: default_pt, color: @fg_color}
|
|
458
|
+
end
|
|
459
|
+
when :bold
|
|
460
|
+
{text: content, size: default_pt, color: @fg_color, styles: [:bold]}
|
|
461
|
+
when :italic
|
|
462
|
+
{text: content, size: default_pt, color: @fg_color, styles: [:italic]}
|
|
463
|
+
when :strikethrough
|
|
464
|
+
{text: content, size: default_pt, color: @fg_color, styles: [:strikethrough]}
|
|
465
|
+
when :code
|
|
466
|
+
{text: " #{content} ", size: default_pt * 0.85, color: @inline_code_color}
|
|
467
|
+
when :note
|
|
468
|
+
{text: content, size: default_pt * 0.7, color: @dim_color}
|
|
469
|
+
when :text
|
|
470
|
+
{text: content, size: default_pt, color: @fg_color}
|
|
471
|
+
else
|
|
472
|
+
{text: content.to_s, size: default_pt, color: @fg_color}
|
|
473
|
+
end
|
|
474
|
+
}
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def max_inline_scale(text)
|
|
478
|
+
max = 0
|
|
479
|
+
text.scan(/\{::tag\s+name="([^"]+)"\}/) do
|
|
480
|
+
scale = Parser::SIZE_SCALES[$1]
|
|
481
|
+
max = scale if scale && scale > max
|
|
482
|
+
end
|
|
483
|
+
max > 0 ? max : nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def heading_margin(pt)
|
|
487
|
+
pt * 0.5
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def estimate_slide_height(slide, content_width, pdf)
|
|
491
|
+
h = 0
|
|
492
|
+
slide.blocks.each do |block|
|
|
493
|
+
case block[:type]
|
|
494
|
+
when :heading
|
|
495
|
+
scale = block[:level] == 1 ? KittyText::HEADING_SCALES[1] : DEFAULT_SCALE
|
|
496
|
+
h += @scale_to_pt[scale] + (block[:level] == 1 ? heading_margin(@scale_to_pt[scale]) : 4)
|
|
497
|
+
when :paragraph
|
|
498
|
+
scale = max_inline_scale(block[:content]) || DEFAULT_SCALE
|
|
499
|
+
h += @scale_to_pt[scale] + 2
|
|
500
|
+
when :code_block
|
|
501
|
+
lines = block[:content].lines.size
|
|
502
|
+
pt = @scale_to_pt[DEFAULT_SCALE] * 0.7
|
|
503
|
+
h += lines * pt * 1.4 + 16 + 6
|
|
504
|
+
when :unordered_list
|
|
505
|
+
h += block[:items].size * (@scale_to_pt[DEFAULT_SCALE] + 6)
|
|
506
|
+
when :ordered_list
|
|
507
|
+
h += block[:items].size * (@scale_to_pt[DEFAULT_SCALE] + 6)
|
|
508
|
+
when :definition_list
|
|
509
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
510
|
+
h += pt + 2 + block[:definition].lines.size * (pt + 2) + 4
|
|
511
|
+
when :blockquote
|
|
512
|
+
pt = @scale_to_pt[DEFAULT_SCALE]
|
|
513
|
+
h += block[:content].lines.size * (pt + 2) + 4
|
|
514
|
+
when :table
|
|
515
|
+
pt = @scale_to_pt[DEFAULT_SCALE] * 0.8
|
|
516
|
+
rows = (block[:header] ? 1 : 0) + block[:rows].size
|
|
517
|
+
h += rows * pt * 1.6 + 4
|
|
518
|
+
when :image
|
|
519
|
+
h += PAGE_HEIGHT * 0.4
|
|
520
|
+
when :blank
|
|
521
|
+
h += @scale_to_pt[DEFAULT_SCALE]
|
|
522
|
+
when :align
|
|
523
|
+
# no height
|
|
524
|
+
else
|
|
525
|
+
h += @scale_to_pt[DEFAULT_SCALE]
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
h
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Render formatted text and return actual rendered height
|
|
532
|
+
def render_formatted(pdf, formatted, at:, width:, align: :left)
|
|
533
|
+
box = Prawn::Text::Formatted::Box.new(
|
|
534
|
+
formatted, at: at, width: width, align: align,
|
|
535
|
+
overflow: :shrink_to_fit, document: pdf
|
|
536
|
+
)
|
|
537
|
+
box.render
|
|
538
|
+
box.height
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def bullet
|
|
542
|
+
@font_registered ? "\u30FB" : "-"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
end
|
|
546
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przn
|
|
4
|
+
class Presentation
|
|
5
|
+
attr_reader :slides, :current
|
|
6
|
+
|
|
7
|
+
def initialize(slides)
|
|
8
|
+
@slides = slides.freeze
|
|
9
|
+
@current = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def current_slide = slides[current]
|
|
13
|
+
def total = slides.size
|
|
14
|
+
def first_slide? = current == 0
|
|
15
|
+
def last_slide? = current == total - 1
|
|
16
|
+
|
|
17
|
+
def next_slide
|
|
18
|
+
@current = [current + 1, total - 1].min
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def prev_slide
|
|
22
|
+
@current = [current - 1, 0].max
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def go_to(n)
|
|
26
|
+
@current = n.clamp(0, total - 1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def first_slide!
|
|
30
|
+
@current = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def last_slide!
|
|
34
|
+
@current = total - 1
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|