slamdown 0.5.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/changelog.md +7 -0
  3. data/lib/slamdown/conversion/configuration.rb +35 -0
  4. data/lib/slamdown/conversion/configuration_validator.rb +235 -0
  5. data/lib/slamdown/conversion/converter.rb +98 -0
  6. data/lib/slamdown/conversion/normalisation.rb +21 -0
  7. data/lib/slamdown/conversion/normalised_node.rb +40 -0
  8. data/lib/slamdown/conversion/normaliser/breaks.rb +104 -0
  9. data/lib/slamdown/conversion/normaliser/content.rb +174 -0
  10. data/lib/slamdown/conversion/normaliser/correction_diagnostics.rb +29 -0
  11. data/lib/slamdown/conversion/normaliser/element_actions.rb +423 -0
  12. data/lib/slamdown/conversion/normaliser/heading_levels.rb +45 -0
  13. data/lib/slamdown/conversion/normaliser/inline_semantics.rb +218 -0
  14. data/lib/slamdown/conversion/normaliser/node_factory.rb +112 -0
  15. data/lib/slamdown/conversion/normaliser/url_rewriter.rb +124 -0
  16. data/lib/slamdown/conversion/normaliser.rb +58 -0
  17. data/lib/slamdown/conversion/policy.rb +178 -0
  18. data/lib/slamdown/conversion/prepared_node.rb +23 -0
  19. data/lib/slamdown/conversion/preparer.rb +122 -0
  20. data/lib/slamdown/conversion/renderer.rb +432 -0
  21. data/lib/slamdown/conversion/rendering/entity_encoder.rb +56 -0
  22. data/lib/slamdown/conversion/rendering/escaper.rb +58 -0
  23. data/lib/slamdown/conversion/rendering/html.rb +183 -0
  24. data/lib/slamdown/conversion/rendering/result.rb +23 -0
  25. data/lib/slamdown/conversion/rendering/strategy.rb +157 -0
  26. data/lib/slamdown/conversion/semantic_extractor.rb +97 -0
  27. data/lib/slamdown/conversion/tables.rb +676 -0
  28. data/lib/slamdown/conversion.rb +34 -0
  29. data/lib/slamdown/diagnostic.rb +34 -0
  30. data/lib/slamdown/document.rb +107 -0
  31. data/lib/slamdown/errors.rb +25 -0
  32. data/lib/slamdown/immutable.rb +23 -0
  33. data/lib/slamdown/output.rb +30 -0
  34. data/lib/slamdown/parsing/html.rb +227 -0
  35. data/lib/slamdown/parsing/node.rb +50 -0
  36. data/lib/slamdown/parsing/parsed_node_adapter.rb +144 -0
  37. data/lib/slamdown/parsing.rb +17 -0
  38. data/lib/slamdown/version.rb +3 -0
  39. data/lib/slamdown.rb +10 -0
  40. data/readme.md +121 -0
  41. metadata +174 -0
@@ -0,0 +1,676 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Interprets normalised table structure and expands cell spans before a renderer selects native syntax or canonical HTML.
7
+ module Tables
8
+ STRUCTURAL_SEMANTICS = [:table, :table_head, :table_body, :table_foot, :table_row, :table_cell, :table_caption, :table_colgroup, :table_column].freeze
9
+ SPAN_ATTRIBUTES = ["colspan", "rowspan"].freeze
10
+ MAX_COLUMNS = 256
11
+ MAX_ROWS = 10_000
12
+ MAX_GRID_POSITIONS = 100_000
13
+ MAX_SPAN = 10_000
14
+
15
+ # Represents one source row before span expansion.
16
+ class Row
17
+ attr_reader :node, :cells
18
+
19
+ def initialize(node, cells)
20
+ @node = node
21
+ @cells = cells.freeze
22
+ freeze
23
+ end
24
+
25
+ def header?
26
+ !cells.empty? && cells.all? { |cell| cell.source_name == "th" }
27
+ end
28
+
29
+ # Coerces mistaken header cells to ordinary body cells without changing their normalised contents.
30
+ def as_body
31
+ body_cells = cells.map do |cell|
32
+ cell.source_name == "th" ? cell.with(:source_name => "td") : cell
33
+ end
34
+ self.class.new(node.with(:children => body_cells), body_cells)
35
+ end
36
+ end
37
+
38
+ # Represents either a source cell or an empty cell created by a source span.
39
+ class Cell
40
+ attr_reader :node, :placeholder_source
41
+
42
+ def initialize(node = nil, placeholder_source = nil, materialise = true)
43
+ @node = node
44
+ @placeholder_source = placeholder_source
45
+ @materialise = materialise
46
+ freeze
47
+ end
48
+
49
+ def placeholder?
50
+ node.nil?
51
+ end
52
+
53
+ def materialise?
54
+ @materialise
55
+ end
56
+ end
57
+
58
+ # Holds one format-specific grouping of a format-neutral source table after the shared grid algorithm has run.
59
+ class Layout
60
+ attr_reader :head_rows, :body_groups, :foot_rows, :complexity_reason
61
+
62
+ def initialize(head_rows, body_groups, foot_rows)
63
+ @ragged_padded = false
64
+ rows = head_rows + body_groups.flatten + foot_rows
65
+ if rows.length > MAX_ROWS
66
+ set_limited(:row_count)
67
+ return
68
+ end
69
+
70
+ expanded_by_row, @complexity_reason = expand(rows)
71
+ if complexity_limited?
72
+ set_empty_groups
73
+ freeze
74
+ return
75
+ end
76
+ expanded_by_row, @ragged_padded, padding_error = pad_ragged_rows(rows, expanded_by_row)
77
+ if padding_error
78
+ set_limited(padding_error)
79
+ return
80
+ end
81
+ @head_rows = map_rows(head_rows, expanded_by_row)
82
+ @body_groups = body_groups.map { |group| map_rows(group, expanded_by_row) }.freeze
83
+ @foot_rows = map_rows(foot_rows, expanded_by_row)
84
+ @expanded_by_node_id = rows.each_with_object({}) { |row, result| result[row.node.object_id] = expanded_by_row.fetch(row.object_id) }.freeze
85
+ freeze
86
+ end
87
+
88
+ def rows
89
+ head_rows + body_groups.inject([]) { |result, group| result + group } + foot_rows
90
+ end
91
+
92
+ def rectangular?
93
+ widths = rows.map(&:length)
94
+ !widths.empty? && widths.first > 0 && widths.uniq.length == 1 && rows.none? { |row| row.any?(&:nil?) }
95
+ end
96
+
97
+ def complexity_limited?
98
+ !complexity_reason.nil?
99
+ end
100
+
101
+ def ragged_padded?
102
+ @ragged_padded
103
+ end
104
+
105
+ def expanded_row(node)
106
+ @expanded_by_node_id[node.object_id]
107
+ end
108
+
109
+ private
110
+
111
+ # Tracks pending row spans as ranges, materialising cells only after every hard native-table gate has passed.
112
+ def expand(rows)
113
+ pending = []
114
+ positions = 0
115
+ result = {}
116
+ rows.each do |row|
117
+ expanded = []
118
+ next_pending = []
119
+ column_cursor = 0
120
+ pending.each do |first_column, last_column, remaining, source, materialise|
121
+ (first_column..last_column).each { |column| expanded[column] = Cell.new(nil, source, materialise) }
122
+ next_pending << [first_column, last_column, remaining - 1, source, materialise] if remaining > 1
123
+ end
124
+
125
+ row.cells.each do |cell_node|
126
+ column_span, column_error = span(cell_node, "colspan")
127
+ row_span, row_error = span(cell_node, "rowspan")
128
+ return [nil, column_error || row_error] if column_error || row_error
129
+
130
+ column = available_column(expanded, column_span, column_cursor)
131
+ return [nil, :column_count] if column + column_span > MAX_COLUMNS
132
+
133
+ expanded[column] = Cell.new(cell_node)
134
+ column_materialise = !Hash[cell_node.attributes].key?("colspan")
135
+ (1...column_span).each { |offset| expanded[column + offset] = Cell.new(nil, cell_node, column_materialise) }
136
+ if row_span > 1
137
+ row_materialise = !Hash[cell_node.attributes].key?("rowspan")
138
+ next_pending << [column, column + column_span - 1, row_span - 1, cell_node, row_materialise]
139
+ end
140
+ column_cursor = column + column_span
141
+ end
142
+
143
+ result[row.object_id] = expanded.freeze
144
+ positions += expanded.length
145
+ return [nil, :grid_positions] if positions > MAX_GRID_POSITIONS
146
+
147
+ pending = next_pending
148
+ end
149
+ [result.freeze, nil]
150
+ end
151
+
152
+ def available_column(expanded, width, starting_column)
153
+ column = starting_column
154
+ column += 1 until (column...(column + width)).all? { |candidate| expanded[candidate].nil? }
155
+ column
156
+ end
157
+
158
+ # Missing trailing cells are a common editor artefact; pad them only after the expanded grid remains within every safety gate.
159
+ def pad_ragged_rows(rows, expanded_by_row)
160
+ widths = rows.map { |row| expanded_by_row.fetch(row.object_id).length }
161
+ return [expanded_by_row, false, nil] if widths.uniq.length <= 1
162
+
163
+ width = widths.max
164
+ return [nil, false, :grid_positions] if width * rows.length > MAX_GRID_POSITIONS
165
+
166
+ padded = rows.each_with_object({}) do |row, result|
167
+ cells = expanded_by_row.fetch(row.object_id)
168
+ missing = width - cells.length
169
+ result[row.object_id] = (cells + Array.new(missing) { Cell.new }).freeze
170
+ end
171
+ [padded.freeze, true, nil]
172
+ end
173
+
174
+ def span(cell, name)
175
+ value = cell.semantic_values.fetch(:table_spans, {})[name]
176
+ return [1, nil] unless value.is_a?(String) && /\A\d+\z/.match(value.strip)
177
+
178
+ span = [value.to_i, 1].max
179
+ span > MAX_SPAN ? [nil, :span] : [span, nil]
180
+ end
181
+
182
+ def map_rows(rows, expanded_by_row)
183
+ rows.map { |row| expanded_by_row.fetch(row.object_id) }.freeze
184
+ end
185
+
186
+ def set_limited(reason)
187
+ @complexity_reason = reason
188
+ set_empty_groups
189
+ freeze
190
+ end
191
+
192
+ def set_empty_groups
193
+ @head_rows = [].freeze
194
+ @body_groups = [].freeze
195
+ @foot_rows = [].freeze
196
+ @expanded_by_node_id = {}.freeze
197
+ end
198
+ end
199
+
200
+ # Carries interpreted groups and the canonical HTML fallback tree for one normalised table.
201
+ class Analysis
202
+ attr_reader :table, :caption, :head_rows, :body_groups, :foot_rows, :html_required
203
+
204
+ def initialize(table, caption, head_rows, body_groups, foot_rows, html_required, captions_merged, dropped_spans)
205
+ @table = table
206
+ @caption = caption
207
+ @head_rows = head_rows.freeze
208
+ @body_groups = body_groups.map(&:freeze).freeze
209
+ @foot_rows = foot_rows.freeze
210
+ @html_required = html_required
211
+ @captions_merged = captions_merged
212
+ @dropped_spans = dropped_spans
213
+ @source_layout = Layout.new(head_rows, body_groups, foot_rows)
214
+ freeze
215
+ end
216
+
217
+ def html_required?
218
+ html_required
219
+ end
220
+
221
+ def captions_merged?
222
+ @captions_merged
223
+ end
224
+
225
+ # Header-cell inference differs only in how many leading all-header rows each output format can express.
226
+ def layout(format)
227
+ heads, bodies, feet = corrected_groups
228
+ if heads.empty? && !bodies.empty?
229
+ first_group = bodies.first
230
+ available = first_group.take_while(&:header?).length
231
+ inferred = format == "markdown" ? [available, 1].min : available
232
+ unless inferred.zero?
233
+ heads = first_group.first(inferred)
234
+ bodies = [first_group.drop(inferred)] + bodies.drop(1)
235
+ bodies.reject!(&:empty?)
236
+ end
237
+ end
238
+ bodies = [bodies.flatten] if format == "markdown" && bodies.length > 1
239
+ Layout.new(heads, bodies, feet)
240
+ end
241
+
242
+ # Produces the corrected structural tree used whenever table syntax falls back to canonical HTML.
243
+ def table_for(format)
244
+ children = @dropped_spans && !@source_layout.complexity_limited? ? materialise_dropped_spans(table.children) : table.children
245
+ children = merge_sections(children, :table_head)
246
+ children = merge_sections(children, :table_foot)
247
+ children = merge_sections(children, :table_body) if format == "markdown"
248
+ children = wrap_direct_rows(children)
249
+ children = coerce_bodyless_sections(children)
250
+ children = children.map { |child| canonicalise_fallback_node(child) }
251
+ children = canonical_table_order(children)
252
+ table.with(:children => children)
253
+ end
254
+
255
+ private
256
+
257
+ def materialise_dropped_spans(nodes)
258
+ nodes.map do |node|
259
+ if node.semantic_kind == :table_row
260
+ cells = @source_layout.expanded_row(node)
261
+ node.with(:children => cells ? materialised_cells(cells, node) : node.children)
262
+ else
263
+ node.with(:children => materialise_dropped_spans(node.children))
264
+ end
265
+ end
266
+ end
267
+
268
+ def materialised_cells(cells, row)
269
+ cells.each_with_object([]) do |cell, result|
270
+ if !cell.placeholder?
271
+ result << cell.node
272
+ elsif cell.materialise?
273
+ result << empty_cell(cell.placeholder_source || row.children.first, row.source_path)
274
+ end
275
+ end
276
+ end
277
+
278
+ def empty_cell(source, path)
279
+ factory = Normaliser::NodeFactory.new
280
+ factory.build(
281
+ :source_name => source && source.source_name == "th" ? "th" : "td",
282
+ :semantic_kind => :table_cell,
283
+ :category => :block,
284
+ :representation => :semantic,
285
+ :synthetic => true,
286
+ :source_path => path
287
+ )
288
+ end
289
+
290
+ def corrected_groups
291
+ heads = head_rows
292
+ bodies = body_groups
293
+ feet = foot_rows
294
+ return [heads, bodies, feet] unless bodies.empty?
295
+
296
+ if !heads.empty? && !feet.empty?
297
+ [heads, [feet], []]
298
+ elsif !heads.empty?
299
+ [[], [heads.map(&:as_body)], []]
300
+ elsif !feet.empty?
301
+ [[], [feet], []]
302
+ else
303
+ [heads, bodies, feet]
304
+ end
305
+ end
306
+
307
+ def merge_sections(children, semantic_kind)
308
+ sections = children.select { |child| child.semantic_kind == semantic_kind }
309
+ return children if sections.length < 2
310
+
311
+ merged = sections.first.with(:children => sections.map(&:children).flatten)
312
+ replaced = false
313
+ children.each_with_object([]) do |child, result|
314
+ if child.semantic_kind == semantic_kind
315
+ unless replaced
316
+ result << merged
317
+ replaced = true
318
+ end
319
+ else
320
+ result << child
321
+ end
322
+ end
323
+ end
324
+
325
+ def coerce_bodyless_sections(children)
326
+ return children if children.any? { |child| [:table_body, :table_row].include?(child.semantic_kind) }
327
+
328
+ head = children.find { |child| child.semantic_kind == :table_head }
329
+ foot = children.find { |child| child.semantic_kind == :table_foot }
330
+ section = foot || head
331
+ return children unless section
332
+
333
+ body = body_section(section, !head.nil? && foot.nil?)
334
+ children.map do |child|
335
+ if child.equal?(section)
336
+ body
337
+ elsif child.semantic_kind == :table_foot
338
+ nil
339
+ else
340
+ child
341
+ end
342
+ end.compact
343
+ end
344
+
345
+ def wrap_direct_rows(children)
346
+ result = []
347
+ rows = []
348
+ flush = lambda do
349
+ unless rows.empty?
350
+ factory = Normaliser::NodeFactory.new
351
+ result << factory.build(
352
+ :source_name => "tbody",
353
+ :semantic_kind => :table_body,
354
+ :category => :block,
355
+ :children => rows,
356
+ :representation => :semantic,
357
+ :synthetic => true,
358
+ :source_path => rows.first.source_path
359
+ )
360
+ rows = []
361
+ end
362
+ end
363
+
364
+ children.each do |child|
365
+ if child.semantic_kind == :table_row
366
+ rows << child
367
+ else
368
+ flush.call
369
+ result << child
370
+ end
371
+ end
372
+ flush.call
373
+ result
374
+ end
375
+
376
+ def canonicalise_fallback_node(node)
377
+ children = node.children.map { |child| canonicalise_fallback_node(child) }
378
+ if node.semantic_kind == :table_cell && children.length > 1 && children.all? { |child| child.category == :block }
379
+ factory = Normaliser::NodeFactory.new
380
+ wrapper = factory.build(
381
+ :source_name => "div",
382
+ :category => :block,
383
+ :children => children,
384
+ :representation => :html,
385
+ :source_path => children.first.source_path
386
+ )
387
+ children = [wrapper]
388
+ end
389
+ node.with(:children => children)
390
+ end
391
+
392
+ def canonical_table_order(children)
393
+ ranks = {
394
+ :table_caption => 0,
395
+ :table_colgroup => 1,
396
+ :table_column => 1,
397
+ :table_head => 2,
398
+ :table_body => 3,
399
+ :table_foot => 4
400
+ }
401
+ children.each_with_index.sort_by { |child, index| [ranks.fetch(child.semantic_kind, 5), index] }.map(&:first)
402
+ end
403
+
404
+ def body_section(section, coerce_header_cells)
405
+ rows = section.children.map do |row|
406
+ next row unless coerce_header_cells && row.semantic_kind == :table_row
407
+
408
+ cells = row.children.map do |cell|
409
+ cell.semantic_kind == :table_cell && cell.source_name == "th" ? cell.with(:source_name => "td") : cell
410
+ end
411
+ row.with(:children => cells)
412
+ end
413
+ section.with(
414
+ :source_name => "tbody",
415
+ :semantic_kind => :table_body,
416
+ :children => rows,
417
+ :representation => :semantic
418
+ )
419
+ end
420
+ end
421
+
422
+ # Reads table groups without rendering or mutating the normalised tree.
423
+ class Analyser
424
+ class << self
425
+ def analyse(table)
426
+ new.analyse(table)
427
+ end
428
+ end
429
+
430
+ def analyse(table)
431
+ raise ArgumentError, "table must be a normalised Slamdown table" unless table.is_a?(NormalisedNode) && table.semantic_kind == :table
432
+
433
+ @credible_width = credible_table_width(table)
434
+ table = sanitise_table_spans(table)
435
+ @html_required = structural_html_required?(table)
436
+ @captions = []
437
+ @head_rows = []
438
+ @body_groups = []
439
+ @foot_rows = []
440
+ @direct_rows = []
441
+ @dropped_spans = false
442
+ cleaned_children = table.children.each_with_object([]) do |child, children|
443
+ next if ignorable_whitespace?(child)
444
+
445
+ cleaned = clean_table_structure(child)
446
+ next unless cleaned
447
+
448
+ interpret_child(cleaned)
449
+ children << cleaned
450
+ end
451
+ flush_direct_rows
452
+ caption, cleaned_children, captions_merged = normalise_captions(cleaned_children)
453
+
454
+ Analysis.new(table.with(:children => cleaned_children), caption, @head_rows, @body_groups, @foot_rows, @html_required, captions_merged, @dropped_spans)
455
+ end
456
+
457
+ private
458
+
459
+ # Infers a safe width only from rows whose retained column spans are already within the hard expansion limit.
460
+ def credible_table_width(table)
461
+ widths = table_rows(table).map do |row|
462
+ cells = row.children.select { |child| child.semantic_kind == :table_cell }
463
+ next if cells.empty?
464
+
465
+ width = 0
466
+ credible = cells.all? do |cell|
467
+ value = Hash[cell.attributes]["colspan"]
468
+ if value.is_a?(String) && /\A\d+\z/.match(value.strip) && value.to_i > MAX_SPAN
469
+ false
470
+ else
471
+ width += valid_span(value)
472
+ true
473
+ end
474
+ end
475
+ width if credible && width <= MAX_COLUMNS
476
+ end.compact
477
+ widths.max
478
+ end
479
+
480
+ def table_rows(node)
481
+ result = node.semantic_kind == :table_row ? [node] : []
482
+ result + node.children.flat_map { |child| table_rows(child) }
483
+ end
484
+
485
+ def sanitise_table_spans(node)
486
+ children = node.children.map { |child| sanitise_table_spans(child) }
487
+ return node.with(:children => children) unless node.semantic_kind == :table_cell
488
+
489
+ attributes = node.attributes.dup
490
+ spans = node.semantic_values.fetch(:table_spans, {}).dup
491
+ ["colspan", "rowspan"].each do |name|
492
+ retained = Hash[attributes][name]
493
+ unless retained
494
+ spans.delete(name)
495
+ next
496
+ end
497
+
498
+ value = retained.strip
499
+ if !/\A\d+\z/.match(value) || value.to_i < 1
500
+ attributes.reject! { |attribute_name, _attribute_value| attribute_name == name }
501
+ spans.delete(name)
502
+ elsif name == "colspan" && value.to_i > MAX_SPAN && @credible_width
503
+ clamped = @credible_width.to_s
504
+ attributes.map! { |attribute_name, attribute_value| [attribute_name, attribute_name == name ? clamped : attribute_value] }
505
+ spans[name] = clamped
506
+ else
507
+ spans[name] = value
508
+ end
509
+ end
510
+
511
+ semantic_values = node.semantic_values.merge(:table_spans => spans)
512
+ semantic_values = semantic_values.reject { |name, value| name == :table_spans && value.empty? }
513
+ node.with(:attributes => attributes, :children => children, :semantic_values => semantic_values)
514
+ end
515
+
516
+ def valid_span(value)
517
+ return 1 unless value.is_a?(String) && /\A\d+\z/.match(value.strip) && value.to_i > 0
518
+
519
+ value.to_i
520
+ end
521
+
522
+ def interpret_child(child)
523
+ case child.semantic_kind
524
+ when :table_caption
525
+ @captions << child
526
+ @html_required ||= structural_html_required?(child)
527
+ when :table_head
528
+ flush_direct_rows
529
+ @head_rows.concat(rows(child))
530
+ @html_required ||= structural_html_required?(child)
531
+ when :table_body
532
+ flush_direct_rows
533
+ group = rows(child)
534
+ @body_groups << group unless group.empty?
535
+ @html_required ||= structural_html_required?(child)
536
+ when :table_foot
537
+ flush_direct_rows
538
+ @foot_rows.concat(rows(child))
539
+ @html_required ||= structural_html_required?(child)
540
+ when :table_row
541
+ @direct_rows << row(child)
542
+ when :table_colgroup, :table_column
543
+ @html_required ||= meaningful_column?(child)
544
+ else
545
+ @html_required = true
546
+ end
547
+ end
548
+
549
+ # Duplicate plain captions are editor debris; merge only where no rich structure or retained metadata can be lost.
550
+ def normalise_captions(children)
551
+ return [nil, children, false] if @captions.empty?
552
+ return [@captions.first, children, false] if @captions.length == 1
553
+ unless @captions.all? { |caption| mergeable_caption?(caption) }
554
+ @html_required = true
555
+ return [@captions.first, children, false]
556
+ end
557
+
558
+ factory = Normaliser::NodeFactory.new
559
+ content = Normaliser::Content.new(factory)
560
+ combined = []
561
+ @captions.each_with_index do |caption, index|
562
+ combined << factory.text(" ", caption.source_path) unless index.zero?
563
+ combined.concat(caption.children)
564
+ end
565
+ merged = @captions.first.with(:children => content.normalise_inline_sequence(combined, :trim => true))
566
+ [merged, replace_captions(children, merged), true]
567
+ end
568
+
569
+ def mergeable_caption?(caption)
570
+ caption.representation == :semantic && !caption.id && caption.classes.empty? && caption.attributes.empty? && caption.children.none? { |child| child.category == :block }
571
+ end
572
+
573
+ def replace_captions(children, merged)
574
+ inserted = false
575
+ children.each_with_object([]) do |child, result|
576
+ if child.semantic_kind == :table_caption
577
+ unless inserted
578
+ result << merged
579
+ inserted = true
580
+ end
581
+ else
582
+ result << child
583
+ end
584
+ end
585
+ end
586
+
587
+ def rows(section)
588
+ section.children.each_with_object([]) do |child, result|
589
+ next if ignorable_whitespace?(child)
590
+
591
+ if child.semantic_kind == :table_row
592
+ result << row(child)
593
+ else
594
+ @html_required = true
595
+ end
596
+ end
597
+ end
598
+
599
+ def row(node)
600
+ @html_required ||= structural_html_required?(node)
601
+ cells = node.children.each_with_object([]) do |child, result|
602
+ next if ignorable_whitespace?(child)
603
+
604
+ if child.semantic_kind == :table_cell
605
+ @html_required ||= structural_html_required?(child)
606
+ @dropped_spans ||= dropped_span?(child)
607
+ result << child
608
+ else
609
+ @html_required = true
610
+ end
611
+ end
612
+ Row.new(node, cells)
613
+ end
614
+
615
+ def flush_direct_rows
616
+ return if @direct_rows.empty?
617
+
618
+ @body_groups << @direct_rows
619
+ @direct_rows = []
620
+ end
621
+
622
+ def structural_html_required?(node)
623
+ return true if node.representation == :html
624
+ return false if node.semantic_kind == :table && node.representation == :semantic
625
+ return false unless STRUCTURAL_SEMANTICS.include?(node.semantic_kind)
626
+
627
+ attributes = node.attributes.reject { |name, _value| node.semantic_kind == :table_cell && name == "scope" }
628
+ !attributes.empty?
629
+ end
630
+
631
+ def dropped_span?(node)
632
+ retained = Hash[node.attributes]
633
+ node.semantic_values.fetch(:table_spans, {}).keys.any? { |name| !retained.key?(name) }
634
+ end
635
+
636
+ def ignorable_whitespace?(node)
637
+ node.kind == :text && node.value.to_s.strip.empty?
638
+ end
639
+
640
+ def clean_table_structure(node)
641
+ cleaned = clean_column_structure(node)
642
+ return unless cleaned
643
+ return cleaned unless STRUCTURAL_SEMANTICS.include?(cleaned.semantic_kind)
644
+
645
+ children = cleaned.children.reject { |child| ignorable_whitespace?(child) }.map { |child| clean_table_structure(child) }.compact
646
+ cleaned.with(:children => children)
647
+ end
648
+
649
+ def meaningful_column?(node)
650
+ own_meaningful_column?(node) || node.children.any? { |child| meaningful_column?(child) }
651
+ end
652
+
653
+ def own_meaningful_column?(node)
654
+ return true if node.representation == :html
655
+
656
+ attributes = node.attributes.reject { |name, _value| name == "span" }
657
+ node.id || !node.classes.empty? || !attributes.empty?
658
+ end
659
+
660
+ def clean_column_structure(node)
661
+ return node unless [:table_colgroup, :table_column].include?(node.semantic_kind)
662
+ return if !meaningful_column?(node)
663
+ return node unless node.semantic_kind == :table_colgroup
664
+
665
+ children = node.children.map { |child| clean_column_structure(child) }.compact
666
+ changes = {:children => children}
667
+ unless own_meaningful_column?(node)
668
+ changes[:attributes] = node.attributes.reject { |name, _value| name == "span" }
669
+ end
670
+ node.with(changes)
671
+ end
672
+ end
673
+ end
674
+
675
+ end
676
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "conversion/configuration"
6
+ require_relative "conversion/configuration_validator"
7
+ require_relative "conversion/policy"
8
+ require_relative "conversion/prepared_node"
9
+ require_relative "conversion/semantic_extractor"
10
+ require_relative "conversion/preparer"
11
+ require_relative "conversion/normalised_node"
12
+ require_relative "conversion/normalisation"
13
+ require_relative "conversion/normaliser/correction_diagnostics"
14
+ require_relative "conversion/normaliser/node_factory"
15
+ require_relative "conversion/normaliser/content"
16
+ require_relative "conversion/normaliser/breaks"
17
+ require_relative "conversion/normaliser/heading_levels"
18
+ require_relative "conversion/normaliser/url_rewriter"
19
+ require_relative "conversion/normaliser/inline_semantics"
20
+ require_relative "conversion/normaliser/element_actions"
21
+ require_relative "conversion/normaliser"
22
+ require_relative "conversion/tables"
23
+ require_relative "conversion/rendering/entity_encoder"
24
+ require_relative "conversion/rendering/escaper"
25
+ require_relative "conversion/rendering/html"
26
+ require_relative "conversion/rendering/strategy"
27
+ require_relative "conversion/rendering/result"
28
+ require_relative "conversion/renderer"
29
+ require_relative "conversion/converter"
30
+
31
+ module Slamdown
32
+ module Conversion
33
+ end
34
+ end