mmmd 0.1.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.
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../util"
4
+
5
+ module MMMD
6
+ module Renderers
7
+ module HTMLConstants
8
+ ELEMENT_MAP = {
9
+ "PointBlank::DOM::InlinePre" => {
10
+ tag: "code",
11
+ style: "white-space: pre;"
12
+ },
13
+ "PointBlank::DOM::InlineBreak" => {
14
+ tag: "br"
15
+ },
16
+ "PointBlank::DOM::InlineStrong" => {
17
+ tag: "strong"
18
+ },
19
+ "PointBlank::DOM::InlineEmphasis" => {
20
+ tag: "em"
21
+ },
22
+ "PointBlank::DOM::InlineUnder" => {
23
+ tag: "span",
24
+ style: "text-decoration: underline;"
25
+ },
26
+ "PointBlank::DOM::InlineStrike" => {
27
+ tag: "s"
28
+ },
29
+ "PointBlank::DOM::InlineLink" => {
30
+ tag: "a",
31
+ href: true,
32
+ title: true
33
+ },
34
+ "PointBlank::DOM::InlineImage" => {
35
+ tag: "img",
36
+ src: true,
37
+ inline: true,
38
+ alt: true,
39
+ title: true
40
+ },
41
+ "PointBlank::DOM::ULBlock" => {
42
+ tag: "ul"
43
+ },
44
+ "PointBlank::DOM::OLBlock" => {
45
+ tag: "ol"
46
+ },
47
+ "PointBlank::DOM::IndentBlock" => {
48
+ tag: "pre"
49
+ },
50
+ "PointBlank::DOM::ULListElement" => {
51
+ tag: "li"
52
+ },
53
+ "PointBlank::DOM::OLListElement" => {
54
+ tag: "li"
55
+ },
56
+ "PointBlank::DOM::Paragraph" => {
57
+ tag: "p"
58
+ },
59
+ "PointBlank::DOM::SetextHeading1" => {
60
+ tag: "h1"
61
+ },
62
+ "PointBlank::DOM::SetextHeading2" => {
63
+ tag: "h2"
64
+ },
65
+ "PointBlank::DOM::ATXHeading1" => {
66
+ tag: "h1"
67
+ },
68
+ "PointBlank::DOM::ATXHeading2" => {
69
+ tag: "h2"
70
+ },
71
+ "PointBlank::DOM::ATXHeading3" => {
72
+ tag: "h3"
73
+ },
74
+ "PointBlank::DOM::ATXHeading4" => {
75
+ tag: "h4"
76
+ },
77
+ "PointBlank::DOM::ATXHeading5" => {
78
+ tag: "h5"
79
+ },
80
+ "PointBlank::DOM::ATXHeading6" => {
81
+ tag: "h6"
82
+ },
83
+ "PointBlank::DOM::Document" => {
84
+ tag: "main"
85
+ },
86
+ "PointBlank::DOM::CodeBlock" => {
87
+ tag: "pre",
88
+ outer: {
89
+ tag: "code"
90
+ }
91
+ },
92
+ "PointBlank::DOM::QuoteBlock" => {
93
+ tag: "blockquote"
94
+ },
95
+ "PointBlank::DOM::HorizontalRule" => {
96
+ tag: "hr",
97
+ inline: true
98
+ },
99
+ "PointBlank::DOM::Text" => {
100
+ sanitize: true
101
+ },
102
+ "PointBlank::DOM::InlineAutolink" => {
103
+ tag: "a",
104
+ href: true
105
+ }
106
+ }.freeze
107
+
108
+ # Class for managing styles and style overrides
109
+ class MapManager
110
+ class << self
111
+ # Define a default mapping for specified class
112
+ # @param key [String] class name
113
+ # @param mapping [Hash] mapping
114
+ # @return [void]
115
+ def define_mapping(key, mapping)
116
+ @mapping ||= ELEMENT_MAP.dup
117
+ @mapping[key] = mapping
118
+ end
119
+
120
+ # Get computed mapping
121
+ # @return [Hash]
122
+ def mapping
123
+ @mapping ||= ELEMENT_MAP.dup
124
+ end
125
+ end
126
+
127
+ def initialize(overrides)
128
+ @mapping = self.class.mapping
129
+ @mapping = @mapping.merge(overrides["mapping"]) if overrides["mapping"]
130
+ end
131
+
132
+ attr_reader :mapping
133
+ end
134
+ end
135
+
136
+ # HTML Renderer
137
+ class HTML
138
+ def initialize(dom, options)
139
+ @document = dom
140
+ @options = options
141
+ @options["linewrap"] ||= 80
142
+ @options["init_level"] ||= 2
143
+ @options["indent"] ||= 2
144
+ mapmanager = HTMLConstants::MapManager.new(options)
145
+ @mapping = mapmanager.mapping
146
+ return unless @options["nowrap"]
147
+
148
+ @options["init_level"] = 0
149
+ @mapping.delete("PointBlank::DOM::Document")
150
+ end
151
+
152
+ # Render document to HTML
153
+ def render
154
+ text = _render(@document, @options, level: @options["init_level"])
155
+ @options["init_level"].times { text = indent(text) }
156
+ if @options["nowrap"]
157
+ text
158
+ else
159
+ [
160
+ preambule,
161
+ remove_pre_spaces(text),
162
+ postambule
163
+ ].join("\n")
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ # Find and remove extra spaces inbetween preformatted text
170
+ # @param string [String]
171
+ # @return [String]
172
+ def remove_pre_spaces(string)
173
+ output = []
174
+ buffer = []
175
+ open = nil
176
+ string.lines.each do |line|
177
+ opentoken = line.match?(/<pre>/)
178
+ closetoken = line.match?(/<\/pre>/)
179
+ if closetoken
180
+ open = false
181
+ buffer = strip_leading_spaces_in_buffer(buffer)
182
+ output.append(*buffer)
183
+ buffer = []
184
+ end
185
+ (open ? buffer : output).append(line)
186
+ open = true if opentoken && !closetoken
187
+ end
188
+ output.append(*buffer) unless buffer.empty?
189
+ output.join('')
190
+ end
191
+
192
+ # Strip leading spaces in the buffer
193
+ # @param lines [Array<String>]
194
+ # @return [Array<String>]
195
+ def strip_leading_spaces_in_buffer(buffer)
196
+ minprefix = buffer.map { |x| x.match(/^ */)[0] }
197
+ .min_by(&:length)
198
+ buffer.map do |line|
199
+ line.delete_prefix(minprefix)
200
+ end
201
+ end
202
+
203
+ # Word wrapping algorithm
204
+ # @param text [String]
205
+ # @param width [Integer]
206
+ # @return [String]
207
+ def wordwrap(text, width)
208
+ words = text.split(/( +|<[^>]+>)/)
209
+ output = []
210
+ line = ""
211
+ length = 0
212
+ until words.empty?
213
+ word = words.shift
214
+ wordlength = word.length
215
+ if length + wordlength + 1 > width
216
+ output.append(line.lstrip)
217
+ line = word
218
+ length = wordlength
219
+ next
220
+ end
221
+ length += wordlength
222
+ line += word
223
+ end
224
+ output.append(line.lstrip)
225
+ output.join("\n")
226
+ end
227
+
228
+ def _render(element, options, inline: false, level: 0, literaltext: false)
229
+ modeswitch = element.is_a?(::PointBlank::DOM::LeafBlock) ||
230
+ element.is_a?(::PointBlank::DOM::Paragraph)
231
+ inline ||= modeswitch
232
+ level += 1 unless inline
233
+ text = if element.children.empty?
234
+ element.content
235
+ else
236
+ literal = @mapping[element.class.name]
237
+ &.fetch(:inline, false) ||
238
+ literaltext
239
+ element.children.map do |child|
240
+ _render(child, options, inline: inline,
241
+ level: level,
242
+ literaltext: literal)
243
+ end.join(inline ? '' : "\n")
244
+ end
245
+ run_filters(text, element, level: level,
246
+ inline: inline,
247
+ modeswitch: modeswitch,
248
+ literaltext: literaltext)
249
+ end
250
+
251
+ def run_filters(text, element, level:, inline:, modeswitch:,
252
+ literaltext:)
253
+ element_style = @mapping[element.class.name]
254
+ return text unless element_style
255
+ return text if literaltext
256
+
257
+ hsize = @options["linewrap"] - (level * @options["indent"])
258
+ text = wordwrap(text, hsize) if modeswitch
259
+ if element_style[:sanitize]
260
+ text = MMMD::EntityUtils.encode_entities(text)
261
+ end
262
+ if element_style[:inline]
263
+ innerclose(element, element_style, text)
264
+ else
265
+ openclose(text, element, element_style, inline)
266
+ end
267
+ end
268
+
269
+ def openclose(text, element, element_style, inline)
270
+ opentag, closetag = construct_tags(element_style, element)
271
+ if inline
272
+ opentag + text + closetag
273
+ else
274
+ [opentag,
275
+ indent(text),
276
+ closetag].join("\n")
277
+ end
278
+ end
279
+
280
+ def innerclose(element, style, text)
281
+ props = element.properties
282
+ tag = "<#{style[:tag]}"
283
+ tag += " style=#{style[:style].inspect}" if style[:style]
284
+ tag += " href=#{read_link(element)}" if style[:href]
285
+ tag += " alt=#{text.inspect}" if style[:alt]
286
+ tag += " src=#{read_link(element)}" if style[:src]
287
+ tag += " title=#{read_title(element)}" if style[:title] && props[:title]
288
+ tag += ">"
289
+ if style[:outer]
290
+ outeropen, outerclose = construct_tags(style[:outer], element)
291
+ tag = outeropen + tag + outerclose
292
+ end
293
+ tag
294
+ end
295
+
296
+ def construct_tags(style, element)
297
+ return ["", ""] unless style && style[:tag]
298
+
299
+ props = element.properties
300
+ opentag = "<#{style[:tag]}"
301
+ closetag = "</#{style[:tag]}>"
302
+ opentag += " style=#{style[:style].inspect}" if style[:style]
303
+ opentag += " href=#{read_link(element)}" if style[:href]
304
+ opentag += " src=#{read_link(element)}" if style[:src]
305
+ opentag += " title=#{read_title(element)}" if style[:title] &&
306
+ props[:title]
307
+ opentag += ">"
308
+ if style[:outer]
309
+ outeropen, outerclose = construct_tags(style[:outer], element)
310
+ opentag = outeropen + opentag
311
+ closetag += outerclose
312
+ end
313
+ [opentag, closetag]
314
+ end
315
+
316
+ def read_title(element)
317
+ title = element.properties[:title]
318
+ title = ::MMMD::EntityUtils.encode_entities(title)
319
+ title.inspect
320
+ end
321
+
322
+ def read_link(element)
323
+ link = element.properties[:uri]
324
+ link.inspect
325
+ end
326
+
327
+ def indent(text)
328
+ text.lines.map do |line|
329
+ "#{' ' * @options["indent"]}#{line}"
330
+ end.join('')
331
+ end
332
+
333
+ def preambule
334
+ head = @options['head']
335
+ headinfo = "#{indent(<<~HEAD.rstrip)}\n " if head
336
+ <head>
337
+ #{head.is_a?(Array) ? head.join("\n") : head}
338
+ </head>
339
+ HEAD
340
+ headinfo ||= " "
341
+ @options['preambule'] or <<~TEXT.rstrip
342
+ <!DOCTYPE HTML>
343
+ <html>
344
+ #{headinfo}<body>
345
+ TEXT
346
+ end
347
+
348
+ def postambule
349
+ @options['postambule'] or <<~TEXT
350
+ </body>
351
+ </html>
352
+ TEXT
353
+ end
354
+ end
355
+ end
356
+ end