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.
- checksums.yaml +7 -0
- data/changelog.md +7 -0
- data/lib/slamdown/conversion/configuration.rb +35 -0
- data/lib/slamdown/conversion/configuration_validator.rb +235 -0
- data/lib/slamdown/conversion/converter.rb +98 -0
- data/lib/slamdown/conversion/normalisation.rb +21 -0
- data/lib/slamdown/conversion/normalised_node.rb +40 -0
- data/lib/slamdown/conversion/normaliser/breaks.rb +104 -0
- data/lib/slamdown/conversion/normaliser/content.rb +174 -0
- data/lib/slamdown/conversion/normaliser/correction_diagnostics.rb +29 -0
- data/lib/slamdown/conversion/normaliser/element_actions.rb +423 -0
- data/lib/slamdown/conversion/normaliser/heading_levels.rb +45 -0
- data/lib/slamdown/conversion/normaliser/inline_semantics.rb +218 -0
- data/lib/slamdown/conversion/normaliser/node_factory.rb +112 -0
- data/lib/slamdown/conversion/normaliser/url_rewriter.rb +124 -0
- data/lib/slamdown/conversion/normaliser.rb +58 -0
- data/lib/slamdown/conversion/policy.rb +178 -0
- data/lib/slamdown/conversion/prepared_node.rb +23 -0
- data/lib/slamdown/conversion/preparer.rb +122 -0
- data/lib/slamdown/conversion/renderer.rb +432 -0
- data/lib/slamdown/conversion/rendering/entity_encoder.rb +56 -0
- data/lib/slamdown/conversion/rendering/escaper.rb +58 -0
- data/lib/slamdown/conversion/rendering/html.rb +183 -0
- data/lib/slamdown/conversion/rendering/result.rb +23 -0
- data/lib/slamdown/conversion/rendering/strategy.rb +157 -0
- data/lib/slamdown/conversion/semantic_extractor.rb +97 -0
- data/lib/slamdown/conversion/tables.rb +676 -0
- data/lib/slamdown/conversion.rb +34 -0
- data/lib/slamdown/diagnostic.rb +34 -0
- data/lib/slamdown/document.rb +107 -0
- data/lib/slamdown/errors.rb +25 -0
- data/lib/slamdown/immutable.rb +23 -0
- data/lib/slamdown/output.rb +30 -0
- data/lib/slamdown/parsing/html.rb +227 -0
- data/lib/slamdown/parsing/node.rb +50 -0
- data/lib/slamdown/parsing/parsed_node_adapter.rb +144 -0
- data/lib/slamdown/parsing.rb +17 -0
- data/lib/slamdown/version.rb +3 -0
- data/lib/slamdown.rb +10 -0
- data/readme.md +121 -0
- 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
|