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.
@@ -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