tty-markdown-meinac 0.7.2

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,1243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown/converter/base"
4
+ require "pastel"
5
+ require "strings"
6
+
7
+ require_relative "decorator"
8
+ require_relative "highlighter"
9
+
10
+ module TTY
11
+ class Markdown
12
+ # Responsible for converting a Markdown document into terminal output
13
+ #
14
+ # @api private
15
+ class Converter < ::Kramdown::Converter::Base
16
+ # The alt attribute name
17
+ #
18
+ # @return [String]
19
+ #
20
+ # @api private
21
+ ALT_ATTRIBUTE = "alt"
22
+ private_constant :ALT_ATTRIBUTE
23
+
24
+ # The HTML comment delimiters pattern
25
+ #
26
+ # @return [Regexp]
27
+ #
28
+ # @api private
29
+ COMMENT_DELIMITERS_PATTERN = /^<!-{2,}\s*|-{2,}>$/.freeze
30
+ private_constant :COMMENT_DELIMITERS_PATTERN
31
+
32
+ # The converted HTML elements
33
+ #
34
+ # @return [Array<String>]
35
+ #
36
+ # @api private
37
+ CONVERTED_HTML_ELEMENTS = %w[a b br del em i img strong].freeze
38
+ private_constant :CONVERTED_HTML_ELEMENTS
39
+
40
+ # The empty string
41
+ #
42
+ # @return [String]
43
+ #
44
+ # @api private
45
+ EMPTY = ""
46
+ private_constant :EMPTY
47
+
48
+ # The href attribute name
49
+ #
50
+ # @return [String]
51
+ #
52
+ # @api private
53
+ HREF_ATTRIBUTE = "href"
54
+ private_constant :HREF_ATTRIBUTE
55
+
56
+ # The mailto scheme pattern
57
+ #
58
+ # @return [Regexp]
59
+ #
60
+ # @api private
61
+ MAILTO_SCHEME_PATTERN = /^mailto:/.freeze
62
+ private_constant :MAILTO_SCHEME_PATTERN
63
+
64
+ # The newline character
65
+ #
66
+ # @return [String]
67
+ #
68
+ # @api private
69
+ NEWLINE = "\n"
70
+ private_constant :NEWLINE
71
+
72
+ # The space character
73
+ #
74
+ # @return [String]
75
+ #
76
+ # @api private
77
+ SPACE = " "
78
+ private_constant :SPACE
79
+
80
+ # The src attribute name
81
+ #
82
+ # @return [String]
83
+ #
84
+ # @api private
85
+ SRC_ATTRIBUTE = "src"
86
+ private_constant :SRC_ATTRIBUTE
87
+
88
+ # The title attribute name
89
+ #
90
+ # @return [String]
91
+ #
92
+ # @api private
93
+ TITLE_ATTRIBUTE = "title"
94
+ private_constant :TITLE_ATTRIBUTE
95
+
96
+ # The UTF-8 characters directive
97
+ #
98
+ # @return [String]
99
+ #
100
+ # @api private
101
+ UTF8_CHARACTERS_DIRECTIVE = "U*"
102
+ private_constant :UTF8_CHARACTERS_DIRECTIVE
103
+
104
+ # Create a {TTY::Markdown::Converter} instance
105
+ #
106
+ # @example
107
+ # converter = TTY::Markdown::Converter.new(document)
108
+ #
109
+ # @param [Kramdown::Element] root
110
+ # the root element
111
+ # @param [Hash] options
112
+ # the root element options
113
+ #
114
+ # @api public
115
+ def initialize(root, options = {})
116
+ super
117
+ pastel = Pastel.new(enabled: options[:enabled])
118
+ @decorator = Decorator.new(pastel, options[:theme])
119
+ @highlighter = Highlighter.new(@decorator, mode: options[:mode])
120
+ @current_indent = 0
121
+ @footnote_number = 1
122
+ @footnotes = {}
123
+ @indent = options[:indent]
124
+ @symbols = options[:symbols]
125
+ @width = options[:width]
126
+ end
127
+
128
+ # Convert an element
129
+ #
130
+ # @example
131
+ # converter.convert(root)
132
+ #
133
+ # @param [Kramdown::Element] element
134
+ # the root element
135
+ # @param [Hash] options
136
+ # the root element options
137
+ #
138
+ # @return [String]
139
+ #
140
+ # @api public
141
+ def convert(element, options = {indent: 0})
142
+ send(:"convert_#{element.type}", element, options)
143
+ end
144
+
145
+ private
146
+
147
+ # The available width without the current indentation
148
+ #
149
+ # @return [Integer]
150
+ #
151
+ # @api private
152
+ def available_width
153
+ @width - @current_indent
154
+ end
155
+
156
+ # Indent content by the indentation level
157
+ #
158
+ # @param [Integer] indentation_level
159
+ # the indentation level
160
+ #
161
+ # @return [void]
162
+ #
163
+ # @api private
164
+ def indent_by(indentation_level)
165
+ @current_indent = indentation_level * @indent
166
+ end
167
+
168
+ # The current space indentation
169
+ #
170
+ # @return [String]
171
+ #
172
+ # @api private
173
+ def indentation
174
+ SPACE * @current_indent
175
+ end
176
+
177
+ # Invoke a block with indentation
178
+ #
179
+ # @param [Boolean] add_indentation
180
+ # whether to add indentation
181
+ #
182
+ # @return [Object]
183
+ #
184
+ # @api private
185
+ def with_indentation(add_indentation: true)
186
+ @current_indent += @indent if add_indentation
187
+ yield.tap do
188
+ @current_indent -= @indent if add_indentation
189
+ end
190
+ end
191
+
192
+ # Transform an element children
193
+ #
194
+ # @param [Kramdown::Element] element
195
+ # the element with child elements
196
+ # @param [Hash] options
197
+ # the element options
198
+ #
199
+ # @return [Array<String>]
200
+ #
201
+ # @api private
202
+ def transform_children(element, options)
203
+ element.children.map.with_index do |child_element, child_index|
204
+ child_options = build_child_options(element, child_index)
205
+ convert(child_element, options.merge(child_options))
206
+ end
207
+ end
208
+
209
+ # Build a child element options
210
+ #
211
+ # @param [Kramdown::Element] element
212
+ # the element with child elements
213
+ # @param [Integer] child_index
214
+ # the child element index
215
+ #
216
+ # @return [Hash]
217
+ #
218
+ # @api private
219
+ def build_child_options(element, child_index)
220
+ {
221
+ index: child_index,
222
+ next: element.children[child_index + 1],
223
+ parent: element,
224
+ prev: child_index > 0 ? element.children[child_index - 1] : nil
225
+ }
226
+ end
227
+
228
+ # Convert a root element
229
+ #
230
+ # @param [Kramdown::Element] element
231
+ # the root element
232
+ # @param [Hash] options
233
+ # the root element options
234
+ #
235
+ # @return [String]
236
+ #
237
+ # @api private
238
+ def convert_root(element, options)
239
+ content = transform_children(element, options)
240
+ return content.join if @footnotes.empty?
241
+
242
+ content.join + build_footnotes_list(root, options)
243
+ end
244
+
245
+ # Build an ordered list of footnotes
246
+ #
247
+ # @param [Kramdown::Element] root
248
+ # the root element
249
+ # @param [Hash] options
250
+ # the root element options
251
+ #
252
+ # @return [String]
253
+ #
254
+ # @api private
255
+ def build_footnotes_list(root, options)
256
+ ol = Kramdown::Element.new(:ol)
257
+ @footnotes.each_value do |footnote|
258
+ value, index = *footnote
259
+ li_options = {index: index, parent: ol}.merge(options)
260
+ li = Kramdown::Element.new(:li, nil, {}, li_options)
261
+ li.children = Marshal.load(Marshal.dump(value.children))
262
+ ol.children << li
263
+ end
264
+ convert_ol(ol, {parent: root}.merge(options))
265
+ end
266
+
267
+ # Convert a header element
268
+ #
269
+ # @param [Kramdown::Element] element
270
+ # the header element
271
+ # @param [Hash] options
272
+ # the header element options
273
+ #
274
+ # @return [Array<String>]
275
+ #
276
+ # @api private
277
+ def convert_header(element, options)
278
+ level = element.options[:level]
279
+ indent_content = options[:parent].type == :root
280
+ indent_by(level - 1) if indent_content
281
+ theme = :"h#{level}"
282
+ content = transform_children(element, options)
283
+ content.join.lines.map do |line|
284
+ "#{indentation}#{@decorator.decorate(line.chomp, theme)}#{NEWLINE}"
285
+ end
286
+ end
287
+
288
+ # Convert a paragraph element
289
+ #
290
+ # @param [Kramdown::Element] element
291
+ # the p element
292
+ # @param [Hash] options
293
+ # the p element options
294
+ #
295
+ # @return [Array<String>]
296
+ #
297
+ # @api private
298
+ def convert_p(element, options)
299
+ parent_type = options[:parent].type
300
+ blockquote_parent = parent_type == :blockquote
301
+ li_parent = parent_type == :li
302
+ content = transform_children(element, options).join
303
+ return "#{content}#{NEWLINE}" if blockquote_parent
304
+
305
+ content.lines.map.with_index do |line, line_index|
306
+ "#{indentation unless line_index.zero? && li_parent}#{line}"
307
+ end.join + NEWLINE
308
+ end
309
+
310
+ # Convert a text element
311
+ #
312
+ # @param [Kramdown::Element] element
313
+ # the text element
314
+ # @param [Hash] options
315
+ # the text element options
316
+ #
317
+ # @return [String]
318
+ #
319
+ # @api private
320
+ def convert_text(element, options)
321
+ text = Strings.wrap(element.value, available_width)
322
+ options[:strip] ? text.chomp : text
323
+ end
324
+
325
+ # Convert a deleted element
326
+ #
327
+ # @param [Kramdown::Element] element
328
+ # the html element
329
+ # @param [Hash] options
330
+ # the html element options
331
+ #
332
+ # @return [Array<String>]
333
+ #
334
+ # @api private
335
+ def convert_del(element, options)
336
+ content = transform_children(element, options).join
337
+ @decorator.decorate_each_line(content, :delete)
338
+ end
339
+
340
+ # Convert a strong element
341
+ #
342
+ # @param [Kramdown::Element] element
343
+ # the strong element
344
+ # @param [Hash] options
345
+ # the strong element options
346
+ #
347
+ # @return [String]
348
+ #
349
+ # @api private
350
+ def convert_strong(element, options)
351
+ content = transform_children(element, options).join
352
+ @decorator.decorate_each_line(content, :strong)
353
+ end
354
+ alias convert_b convert_strong
355
+
356
+ # Convert an emphasis element
357
+ #
358
+ # @param [Kramdown::Element] element
359
+ # the em element
360
+ # @param [Hash] options
361
+ # the em element options
362
+ #
363
+ # @return [String]
364
+ #
365
+ # @api private
366
+ def convert_em(element, options)
367
+ content = transform_children(element, options).join
368
+ @decorator.decorate_each_line(content, :em)
369
+ end
370
+ alias convert_i convert_em
371
+
372
+ # Convert a blank element
373
+ #
374
+ # @return [String]
375
+ #
376
+ # @api private
377
+ def convert_blank(*)
378
+ NEWLINE
379
+ end
380
+
381
+ # Convert a smart quote element
382
+ #
383
+ # @param [Kramdown::Element] element
384
+ # the smart quote element
385
+ # @param [Hash] options
386
+ # the smart quote element options
387
+ #
388
+ # @return [String]
389
+ #
390
+ # @api private
391
+ def convert_smart_quote(element, options)
392
+ @symbols[element.value]
393
+ end
394
+
395
+ # Convert a codespan element
396
+ #
397
+ # @param [Kramdown::Element] element
398
+ # the codespan element
399
+ # @param [Hash] options
400
+ # the codespan element options
401
+ #
402
+ # @return [String]
403
+ #
404
+ # @api private
405
+ def convert_codespan(element, options)
406
+ code = Strings.wrap(element.value, available_width)
407
+ language = element.options[:lang]
408
+ highlighted = @highlighter.highlight(code, language)
409
+ highlighted.lines.map.with_index do |line, line_index|
410
+ "#{indentation unless line_index.zero?}#{line.chomp}"
411
+ end.join(NEWLINE)
412
+ end
413
+
414
+ # Convert a codeblock element
415
+ #
416
+ # @param [Kramdown::Element] element
417
+ # the codeblock element
418
+ # @param [Hash] options
419
+ # the codeblock element options
420
+ #
421
+ # @return [String]
422
+ #
423
+ # @api private
424
+ def convert_codeblock(element, options)
425
+ "#{indentation}#{convert_codespan(element, options)}#{NEWLINE}"
426
+ end
427
+
428
+ # Convert a blockquote element
429
+ #
430
+ # @param [Kramdown::Element] element
431
+ # the blockquote element
432
+ # @param [Hash] options
433
+ # the blockquote element options
434
+ #
435
+ # @return [Array<String>]
436
+ #
437
+ # @api private
438
+ def convert_blockquote(element, options)
439
+ bar = @decorator.decorate(@symbols[:bar], :quote)
440
+ prefix = "#{indentation}#{bar} "
441
+ content = transform_children(element, options)
442
+ content.join.lines.map do |line|
443
+ "#{prefix}#{line}"
444
+ end
445
+ end
446
+
447
+ # Convert a description, ordered or unordered list element
448
+ #
449
+ # @param [Kramdown::Element] element
450
+ # the dl, ol or ul element
451
+ # @param [Hash] options
452
+ # the dl, ol or ul element options
453
+ #
454
+ # @return [String]
455
+ #
456
+ # @api private
457
+ def convert_ul(element, options)
458
+ indent_content = options[:parent].type != :root
459
+ content = with_indentation(add_indentation: indent_content) do
460
+ transform_children(element, options)
461
+ end
462
+ content.join
463
+ end
464
+ alias convert_ol convert_ul
465
+ alias convert_dl convert_ul
466
+
467
+ # Convert a list item element
468
+ #
469
+ # @param [Kramdown::Element] element
470
+ # the li element
471
+ # @param [Hash] options
472
+ # the li element options
473
+ #
474
+ # @return [String]
475
+ #
476
+ # @api private
477
+ def convert_li(element, options)
478
+ index = options[:index] + 1
479
+ parent_type = options[:parent].type
480
+ prefix_type = parent_type == :ol ? "#{index}." : @symbols[:bullet]
481
+ prefix = "#{@decorator.decorate(prefix_type, :list)} "
482
+ options[:strip] = true
483
+ content = transform_children(element, options)
484
+ "#{indentation}#{prefix}#{content.join}"
485
+ end
486
+
487
+ # Convert a description term element
488
+ #
489
+ # @param [Kramdown::Element] element
490
+ # the dt element
491
+ # @param [Hash] options
492
+ # the dt element options
493
+ #
494
+ # @return [String]
495
+ #
496
+ # @api private
497
+ def convert_dt(element, options)
498
+ content = transform_children(element, options)
499
+ "#{indentation}#{content.join}#{NEWLINE}"
500
+ end
501
+
502
+ # Convert a description details element
503
+ #
504
+ # @param [Kramdown::Element] element
505
+ # the dd element
506
+ # @param [Hash] options
507
+ # the dd element options
508
+ #
509
+ # @return [Array<String>]
510
+ #
511
+ # @api private
512
+ def convert_dd(element, options)
513
+ next_type = options[:next] && options[:next].type
514
+ suffix = next_type == :dt ? NEWLINE : EMPTY
515
+ content = with_indentation do
516
+ transform_children(element, options)
517
+ end
518
+ "#{content.join}#{suffix}"
519
+ end
520
+
521
+ # Convert a table element
522
+ #
523
+ # @param [Kramdown::Element] element
524
+ # the table element
525
+ # @param [Hash] options
526
+ # the table element options
527
+ #
528
+ # @return [String]
529
+ #
530
+ # @api private
531
+ def convert_table(element, options)
532
+ initialize_table
533
+ column_alignments = element.options[:alignment]
534
+ table_data = extract_table_data(element, options)
535
+ table_options = build_table_options(table_data, column_alignments)
536
+ transform_children(element, options.merge(table_options)).join
537
+ end
538
+
539
+ # Initialise a table
540
+ #
541
+ # @return [void]
542
+ #
543
+ # @api private
544
+ def initialize_table
545
+ @column = 0
546
+ @row = 0
547
+ end
548
+
549
+ # Extract the table data
550
+ #
551
+ # @param [Kramdown::Element] element
552
+ # the table element
553
+ # @param [Hash] options
554
+ # the table element options
555
+ #
556
+ # @return [Array<Array<String>>]
557
+ #
558
+ # @api private
559
+ def extract_table_data(element, options)
560
+ element.children.each_with_object([]) do |child_element, data|
561
+ child_element.children.each do |row|
562
+ data << row.children.map do |cell|
563
+ transform_children(cell, options)
564
+ end
565
+ end
566
+ end
567
+ end
568
+
569
+ # Build a table element options
570
+ #
571
+ # @param [Array<Array<String>>] table_data
572
+ # the table data
573
+ # @param [Array<Symbol>] column_alignments
574
+ # the table column alignments
575
+ #
576
+ # @return [Hash]
577
+ #
578
+ # @api private
579
+ def build_table_options(table_data, column_alignments)
580
+ max_column_widths = calculate_max_column_widths(table_data)
581
+ column_widths = distribute_column_widths(max_column_widths)
582
+ row_heights = calculate_max_row_heights(table_data, column_widths)
583
+ {
584
+ column_alignments: column_alignments,
585
+ column_widths: column_widths,
586
+ row_heights: row_heights,
587
+ table_data: table_data
588
+ }
589
+ end
590
+
591
+ # Distribute column widths within the total width
592
+ #
593
+ # @param [Array<Integer>] column_widths
594
+ # the table column widths
595
+ #
596
+ # @return [Array<Integer>]
597
+ #
598
+ # @api private
599
+ def distribute_column_widths(column_widths)
600
+ borders_width = (column_widths.size + 1)
601
+ indentation_width = (indentation.length + 1) * 2
602
+ screen_width = @width - borders_width - indentation_width
603
+ total_width = column_widths.reduce(&:+)
604
+ return column_widths if total_width <= screen_width
605
+
606
+ reduce_column_widths(column_widths, total_width, screen_width)
607
+ end
608
+
609
+ # Reduce column widths to the screen width
610
+ #
611
+ # @param [Array<Integer>] column_widths
612
+ # the table column widths
613
+ # @param [Integer] total_width
614
+ # the total width of the table columns
615
+ # @param [Integer] screen_width
616
+ # the screen width
617
+ #
618
+ # @return [Array<Integer>]
619
+ #
620
+ # @api private
621
+ def reduce_column_widths(column_widths, total_width, screen_width)
622
+ extra_width = total_width - screen_width
623
+ column_widths.map do |column_width|
624
+ ratio = column_width / total_width.to_f
625
+ column_width - (extra_width * ratio).floor
626
+ end
627
+ end
628
+
629
+ # Calculate maximum widths for every column
630
+ #
631
+ # @param [Array<Array<String>>] table_data
632
+ # the table data
633
+ #
634
+ # @return [Array<Integer>]
635
+ #
636
+ # @api private
637
+ def calculate_max_column_widths(table_data)
638
+ table_data.first.map.with_index do |_, column_index|
639
+ calculate_max_column_width(table_data, column_index)
640
+ end
641
+ end
642
+
643
+ # Calculate the maximum table cell width for a given column index
644
+ #
645
+ # @param [Array<Array<String>>] table_data
646
+ # the table data
647
+ # @param [Integer] column_index
648
+ # the table column index
649
+ #
650
+ # @return [Integer]
651
+ #
652
+ # @api private
653
+ def calculate_max_column_width(table_data, column_index)
654
+ table_data.map do |row|
655
+ Strings.sanitize(row[column_index].join).lines.map(&:length).max || 0
656
+ end.max
657
+ end
658
+
659
+ # Calculate maximum heights for every row
660
+ #
661
+ # @param [Array<Array<String>>] table_data
662
+ # the table data
663
+ # @param [Array<Integer>] column_widths
664
+ # the table column widths
665
+ #
666
+ # @return [Array<Integer>]
667
+ #
668
+ # @api private
669
+ def calculate_max_row_heights(table_data, column_widths)
670
+ table_data.map do |row|
671
+ calculate_max_row_height(row, column_widths)
672
+ end
673
+ end
674
+
675
+ # Calculate the maximum table cell height for a given row
676
+ #
677
+ # @param [Array<Array<String>>] row
678
+ # the table row
679
+ # @param [Array<Integer>] column_widths
680
+ # the table column widths
681
+ #
682
+ # @return [Integer]
683
+ #
684
+ # @api private
685
+ def calculate_max_row_height(row, column_widths)
686
+ row.map.with_index do |cell, column_index|
687
+ Strings.wrap(cell.join, column_widths[column_index]).lines.size
688
+ end.max
689
+ end
690
+
691
+ # Convert a table head element
692
+ #
693
+ # @param [Kramdown::Element] element
694
+ # the thead element
695
+ # @param [Hash] options
696
+ # the thead element options
697
+ #
698
+ # @return [String]
699
+ #
700
+ # @api private
701
+ def convert_thead(element, options)
702
+ top_border = build_border(:top, options[:column_widths])
703
+ content = transform_children(element, options)
704
+ "#{indentation}#{top_border}#{NEWLINE}#{content.join}"
705
+ end
706
+
707
+ # Build a horizontal border line
708
+ #
709
+ # @param [Symbol] location
710
+ # the location out of :bottom, :mid or :top
711
+ # @param [Array<Integer>] column_widths
712
+ # the table column widths
713
+ #
714
+ # @return [String]
715
+ #
716
+ # @api private
717
+ def build_border(location, column_widths)
718
+ border = [@symbols[:"#{location}_left"]]
719
+ column_widths.each.with_index do |column_width, column_index|
720
+ border << @symbols[:"#{location}_center"] unless column_index.zero?
721
+ border << (@symbols[:line] * (column_width + 2))
722
+ end
723
+ border << @symbols[:"#{location}_right"]
724
+ @decorator.decorate(border.join, :table)
725
+ end
726
+
727
+ # Convert a table body element
728
+ #
729
+ # @param [Kramdown::Element] element
730
+ # the tbody element
731
+ # @param [Hash] options
732
+ # the tbody element options
733
+ #
734
+ # @return [String]
735
+ #
736
+ # @api private
737
+ def convert_tbody(element, options)
738
+ column_widths = options[:column_widths]
739
+ next_type = options[:next] && options[:next].type
740
+ prev_type = options[:prev] && options[:prev].type
741
+ top_border_type = prev_type == :thead ? :mid : :top
742
+ top_border = build_border(top_border_type, column_widths)
743
+ bottom_border_type = next_type == :tfoot ? :mid : :bottom
744
+ bottom_border = build_border(bottom_border_type, column_widths)
745
+ content = transform_children(element, options)
746
+ "#{indentation}#{top_border}#{NEWLINE}#{content.join}" \
747
+ "#{indentation}#{bottom_border}#{NEWLINE}"
748
+ end
749
+
750
+ # Convert a table foot element
751
+ #
752
+ # @param [Kramdown::Element] element
753
+ # the tfoot element
754
+ # @param [Hash] options
755
+ # the tfoot element options
756
+ #
757
+ # @return [String]
758
+ #
759
+ # @api private
760
+ def convert_tfoot(element, options)
761
+ bottom_border = build_border(:bottom, options[:column_widths])
762
+ content = transform_children(element, options)
763
+ "#{content.join}#{indentation}#{bottom_border}#{NEWLINE}"
764
+ end
765
+
766
+ # Convert a table row element
767
+ #
768
+ # @param [Kramdown::Element] element
769
+ # the tr element
770
+ # @param [Hash] options
771
+ # the tr element options
772
+ #
773
+ # @return [String]
774
+ #
775
+ # @api private
776
+ def convert_tr(element, options)
777
+ add_border = options[:prev] && options[:prev].type == :tr
778
+ border = add_border ? build_row_border(options[:column_widths]) : EMPTY
779
+ content = transform_children(element, options)
780
+ move_to_next_row
781
+ "#{border}#{format_table_row(content)}"
782
+ end
783
+
784
+ # Build a table row border
785
+ #
786
+ # @param [Array<Integer>] column_widths
787
+ # the table column widths
788
+ #
789
+ # @return [String]
790
+ #
791
+ # @api private
792
+ def build_row_border(column_widths)
793
+ "#{indentation}#{build_border(:mid, column_widths)}#{NEWLINE}"
794
+ end
795
+
796
+ # Move to the next table row
797
+ #
798
+ # @return [void]
799
+ #
800
+ # @api private
801
+ def move_to_next_row
802
+ @row += 1
803
+ end
804
+
805
+ # Format a table row
806
+ #
807
+ # @param [String] content
808
+ # the content to format
809
+ #
810
+ # @return [String]
811
+ #
812
+ # @api private
813
+ def format_table_row(content)
814
+ number_of_columns = content.size
815
+ last_column_index = number_of_columns - 1
816
+ content.each_with_object([]).with_index do |(cell, row), column_index|
817
+ append_newline = column_index == last_column_index
818
+ insert_cell_into_row(cell, row, append_newline)
819
+ end.join
820
+ end
821
+
822
+ # Insert a cell into a table row
823
+ #
824
+ # @param [Array<String>] cell
825
+ # the cell to insert
826
+ # @param [Array] row
827
+ # the row to insert into
828
+ # @param [Boolean] append_newline
829
+ # whether to append a newline
830
+ #
831
+ # @return [void]
832
+ #
833
+ # @api private
834
+ def insert_cell_into_row(cell, row, append_newline)
835
+ cell.each_with_index do |cell_line, cell_line_index|
836
+ (row[cell_line_index] ||= []) << cell_line.chomp
837
+ row[cell_line_index] << NEWLINE if append_newline
838
+ end
839
+ end
840
+
841
+ # Convert a table data element
842
+ #
843
+ # @param [Kramdown::Element] element
844
+ # the td element
845
+ # @param [Hash] options
846
+ # the td element options
847
+ #
848
+ # @return [Array<String>]
849
+ #
850
+ # @api private
851
+ def convert_td(element, options)
852
+ add_indentation = @column.zero?
853
+ cell_content = transform_children(element, options).join
854
+ formatted_cell = format_table_cell(cell_content, options)
855
+ number_of_columns = options[:column_widths].size
856
+ cycle_to_next_column(number_of_columns)
857
+ decorate_table_cell(formatted_cell, add_indentation)
858
+ end
859
+
860
+ # Cycle to the next table column
861
+ #
862
+ # @param [Integer] number_of_columns
863
+ # the number of table columns
864
+ #
865
+ # @return [void]
866
+ #
867
+ # @api private
868
+ def cycle_to_next_column(number_of_columns)
869
+ @column = (@column + 1) % number_of_columns
870
+ end
871
+
872
+ # Format a table cell
873
+ #
874
+ # @param [String] content
875
+ # the content to format
876
+ # @param [Hash] options
877
+ # the element options
878
+ #
879
+ # @return [String]
880
+ #
881
+ # @api private
882
+ def format_table_cell(content, options)
883
+ alignment = options[:column_alignments][@column]
884
+ align_options = alignment == :default ? {} : {direction: alignment}
885
+ cell_height = options[:row_heights][@row]
886
+ cell_width = options[:column_widths][@column]
887
+ wrapped = Strings.wrap(content, cell_width)
888
+ aligned = Strings.align(wrapped, cell_width, **align_options)
889
+ return aligned if aligned.lines.size == cell_height
890
+
891
+ Strings.pad(aligned, [0, 0, cell_height - aligned.lines.size, 0])
892
+ end
893
+
894
+ # Decorate a table cell
895
+ #
896
+ # @param [String] content
897
+ # the content to decorate
898
+ # @param [Boolean] add_indentation
899
+ # whether to add indentation
900
+ #
901
+ # @return [Array<String>]
902
+ #
903
+ # @api private
904
+ def decorate_table_cell(content, add_indentation)
905
+ pipe = @decorator.decorate(@symbols[:pipe], :table)
906
+ prefix = add_indentation ? "#{indentation}#{pipe} " : EMPTY
907
+ suffix = " #{pipe} "
908
+ content.lines.map do |line|
909
+ suffix_insert_index = line.end_with?(NEWLINE) ? -2 : -1
910
+ "#{prefix}#{line.insert(suffix_insert_index, suffix)}"
911
+ end
912
+ end
913
+
914
+ # Convert a line break element
915
+ #
916
+ # @param [Kramdown::Element] element
917
+ # the br element
918
+ # @param [Hash] options
919
+ # the br element options
920
+ #
921
+ # @return [String]
922
+ #
923
+ # @api private
924
+ def convert_br(element, options)
925
+ NEWLINE
926
+ end
927
+
928
+ # Convert a horizontal rule element
929
+ #
930
+ # @param [Kramdown::Element] element
931
+ # the hr element
932
+ # @param [Hash] options
933
+ # the hr element options
934
+ #
935
+ # @return [String]
936
+ #
937
+ # @api private
938
+ def convert_hr(element, options)
939
+ inner_line_width = @width - (@symbols[:diamond].length * 2)
940
+ inner_line = @symbols[:line] * inner_line_width
941
+ line = "#{@symbols[:diamond]}#{inner_line}#{@symbols[:diamond]}"
942
+ "#{@decorator.decorate(line, :hr)}#{NEWLINE}"
943
+ end
944
+
945
+ # Convert an anchor element
946
+ #
947
+ # @param [Kramdown::Element] element
948
+ # the a element
949
+ # @param [Hash] options
950
+ # the a element options
951
+ #
952
+ # @return [Array<String>]
953
+ #
954
+ # @api private
955
+ def convert_a(element, options)
956
+ content = transform_children(element, options).join
957
+ return [] if content.strip.empty?
958
+
959
+ href = strip_mailto_scheme(element.attr[HREF_ATTRIBUTE])
960
+ title = element.attr[TITLE_ATTRIBUTE].to_s
961
+ build_link(content, href, title)
962
+ end
963
+
964
+ # Strip the mailto scheme from the href attribute
965
+ #
966
+ # @param [String] href
967
+ # the href attribute
968
+ #
969
+ # @return [String]
970
+ #
971
+ # @api private
972
+ def strip_mailto_scheme(href)
973
+ href.sub(MAILTO_SCHEME_PATTERN, EMPTY)
974
+ end
975
+
976
+ # Build a link
977
+ #
978
+ # @param [String] content
979
+ # the link content
980
+ # @param [String] href
981
+ # the link href attribute
982
+ # @param [String] title
983
+ # the link title attribute
984
+ #
985
+ # @return [Array<String>]
986
+ #
987
+ # @api private
988
+ def build_link(content, href, title)
989
+ link = []
990
+ link << "#{content} #{@symbols[:arrow]} " if content != href
991
+ link << "(#{title}) " unless title.strip.empty?
992
+ link << @decorator.decorate(href, :link)
993
+ end
994
+
995
+ # Convert a math element
996
+ #
997
+ # @param [Kramdown::Element] element
998
+ # the math element
999
+ # @param [Hash] options
1000
+ # the math element options
1001
+ #
1002
+ # @return [String]
1003
+ #
1004
+ # @api private
1005
+ def convert_math(element, options)
1006
+ if element.options[:category] == :block
1007
+ convert_codeblock(element, options)
1008
+ else
1009
+ convert_codespan(element, options)
1010
+ end
1011
+ end
1012
+
1013
+ # Convert an abbreviation element
1014
+ #
1015
+ # @param [Kramdown::Element] element
1016
+ # the abbreviation element
1017
+ # @param [Hash] options
1018
+ # the abbreviation element options
1019
+ #
1020
+ # @return [String]
1021
+ #
1022
+ # @api private
1023
+ def convert_abbreviation(element, options)
1024
+ title = @root.options[:abbrev_defs][element.value]
1025
+ if title.to_s.empty?
1026
+ element.value
1027
+ else
1028
+ "#{element.value}(#{title})"
1029
+ end
1030
+ end
1031
+
1032
+ # Convert a typographic symbol element
1033
+ #
1034
+ # @param [Kramdown::Element] element
1035
+ # the typographic sym element
1036
+ # @param [Hash] options
1037
+ # the typographic sym element options
1038
+ #
1039
+ # @return [String]
1040
+ #
1041
+ # @api private
1042
+ def convert_typographic_sym(element, options)
1043
+ @symbols[element.value]
1044
+ end
1045
+
1046
+ # Convert an entity element
1047
+ #
1048
+ # @param [Kramdown::Element] element
1049
+ # the entity element
1050
+ # @param [Hash] options
1051
+ # the entity element options
1052
+ #
1053
+ # @return [String]
1054
+ #
1055
+ # @api private
1056
+ def convert_entity(element, options)
1057
+ transform_codepoint(element.value.code_point)
1058
+ end
1059
+
1060
+ # Transform a codepoint into a UTF-8 character
1061
+ #
1062
+ # @param [Integer] codepoint
1063
+ # the codepoint to transform
1064
+ #
1065
+ # @return [String]
1066
+ #
1067
+ # @api private
1068
+ def transform_codepoint(codepoint)
1069
+ [codepoint].pack(UTF8_CHARACTERS_DIRECTIVE)
1070
+ end
1071
+
1072
+ # Convert a footnote element
1073
+ #
1074
+ # @param [Kramdown::Element] element
1075
+ # the footnote element
1076
+ # @param [Hash] options
1077
+ # the footnote element options
1078
+ #
1079
+ # @return [String]
1080
+ #
1081
+ # @api private
1082
+ def convert_footnote(element, options)
1083
+ name = element.options[:name]
1084
+ content = element.value
1085
+ footnote = fetch_or_add_footnote(name, content)
1086
+ number = footnote.last
1087
+ @decorator.decorate(@symbols.wrap_in_brackets(number), :note)
1088
+ end
1089
+
1090
+ # Fetch or add a footnote
1091
+ #
1092
+ # @param [String] name
1093
+ # the footnote name
1094
+ # @param [String] content
1095
+ # the footnote content
1096
+ #
1097
+ # @return [Array<Integer, String>]
1098
+ #
1099
+ # @api private
1100
+ def fetch_or_add_footnote(name, content)
1101
+ @footnotes.fetch(name) do
1102
+ add_footnote(name, content).tap do
1103
+ increment_footnote_number
1104
+ end
1105
+ end
1106
+ end
1107
+
1108
+ # Add a footnote
1109
+ #
1110
+ # @param [String] name
1111
+ # the footnote name
1112
+ # @param [String] content
1113
+ # the footnote content
1114
+ #
1115
+ # @return [Array<Integer, String>]
1116
+ #
1117
+ # @api private
1118
+ def add_footnote(name, content)
1119
+ @footnotes[name] = [content, @footnote_number]
1120
+ end
1121
+
1122
+ # Increment a footnote number
1123
+ #
1124
+ # @return [Integer]
1125
+ #
1126
+ # @api private
1127
+ def increment_footnote_number
1128
+ @footnote_number += 1
1129
+ end
1130
+
1131
+ # Convert a raw element
1132
+ #
1133
+ # @return [String]
1134
+ #
1135
+ # @api private
1136
+ def convert_raw(*)
1137
+ warning("Raw content is not supported")
1138
+ end
1139
+
1140
+ # Convert an image element
1141
+ #
1142
+ # @param [Kramdown::Element] element
1143
+ # the img element
1144
+ # @param [Hash] options
1145
+ # the img element options
1146
+ #
1147
+ # @return [String]
1148
+ #
1149
+ # @api private
1150
+ def convert_img(element, options)
1151
+ alt = element.attr[ALT_ATTRIBUTE].to_s
1152
+ src = element.attr[SRC_ATTRIBUTE].to_s
1153
+ image = build_image(alt, src)
1154
+ @decorator.decorate(@symbols.wrap_in_parentheses(image), :image)
1155
+ end
1156
+
1157
+ # Build an image
1158
+ #
1159
+ # @param [String] alt
1160
+ # the image alt attribute
1161
+ # @param [String] src
1162
+ # the image src attribute
1163
+ #
1164
+ # @return [String]
1165
+ #
1166
+ # @api private
1167
+ def build_image(alt, src)
1168
+ return src if alt.empty?
1169
+
1170
+ "#{alt} #{@symbols[:ndash]} #{src}"
1171
+ end
1172
+
1173
+ # Convert an HTML element
1174
+ #
1175
+ # @param [Kramdown::Element] element
1176
+ # the html element
1177
+ # @param [Hash] options
1178
+ # the html element options
1179
+ #
1180
+ # @return [Array<String>, String]
1181
+ #
1182
+ # @api private
1183
+ def convert_html_element(element, options)
1184
+ if CONVERTED_HTML_ELEMENTS.include?(element.value)
1185
+ send(:"convert_#{element.value}", element, options)
1186
+ elsif element.children.any?
1187
+ transform_children(element, options)
1188
+ else
1189
+ warning("HTML element '#{element.value.inspect}' not supported")
1190
+ EMPTY
1191
+ end
1192
+ end
1193
+
1194
+ # Convert an XML comment element
1195
+ #
1196
+ # @param [Kramdown::Element] element
1197
+ # the xml comment element
1198
+ # @param [Hash] options
1199
+ # the xml comment element options
1200
+ #
1201
+ # @return [String]
1202
+ #
1203
+ # @api private
1204
+ def convert_xml_comment(element, options)
1205
+ inline_level = element.options[:category] == :span
1206
+ content = strip_comment_delimiters(element.value)
1207
+ comment = build_comment(content, inline_level)
1208
+ inline_level ? comment : "#{comment}#{NEWLINE}"
1209
+ end
1210
+ alias convert_comment convert_xml_comment
1211
+
1212
+ # Strip the delimiters from the HTML comment
1213
+ #
1214
+ # @param [String] comment
1215
+ # the HTML comment
1216
+ #
1217
+ # @return [String]
1218
+ #
1219
+ # @api private
1220
+ def strip_comment_delimiters(comment)
1221
+ comment.gsub(COMMENT_DELIMITERS_PATTERN, EMPTY)
1222
+ end
1223
+
1224
+ # Build a comment
1225
+ #
1226
+ # @param [String] content
1227
+ # the comment content
1228
+ # @param [Boolean] inline_level
1229
+ # whether the comment level is inline
1230
+ #
1231
+ # @return [String]
1232
+ #
1233
+ # @api private
1234
+ def build_comment(content, inline_level)
1235
+ comment_indentation = inline_level ? EMPTY : indentation
1236
+ content.lines.map do |line|
1237
+ comment_indentation +
1238
+ @decorator.decorate("#{@symbols[:hash]} #{line.chomp}", :comment)
1239
+ end.join(NEWLINE)
1240
+ end
1241
+ end # Converter
1242
+ end # Markdown
1243
+ end # TTY