nokodown 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,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ RenderedImage = Struct.new(:url, :redundant, :targets)
6
+
7
+ # Renders a run of inline nodes (text, emphasis, code, links, images) to
8
+ # one string. Line breaks from <br> are kept as "\n".
9
+ class Inline
10
+ EMPHASIS = { "strong" => "**", "b" => "**", "em" => "*", "i" => "*", "del" => "~~", "s" => "~~", "strike" => "~~" }.freeze
11
+ CODE = Set.new(%w[code kbd samp tt]).freeze
12
+ UNSAFE_SCHEME = /\A\s*(?:javascript|vbscript|data|tel|sms):/i.freeze
13
+ FOOTNOTE_MARKER = /\A\s*(?:\[[^\]]{1,8}\]|[*†‡§]+)\s*\z/.freeze
14
+ # Exponents are often written with the minus sign "−", not "-".
15
+ SCRIPT_TEXT = /\A[0-9+\-−=()]+(?:,[0-9+\-−=()]+)*\z/.freeze
16
+ SCRIPT_SOURCE = "0123456789+−=()-"
17
+ SUPERSCRIPTS = "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾⁻"
18
+ SUBSCRIPTS = "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎₋"
19
+ # A point or a comma only between digits or letters: 10^(2.5), a_(i,j).
20
+ SCRIPT_WORD = %r{\A(?:[\p{L}\p{N}+\-−=/()]|(?<=[\p{L}\p{N}])[.,](?=[\p{L}\p{N}]))+\z}.freeze
21
+ SCRIPT_OPERATOR = %r{[+\-−=/]}.freeze
22
+ GROUPED = /\A(\((?:[^()]|\g<1>)*\))\z/.freeze
23
+ # These have characters of their own (1º, nº, Acme™); a raised o before
24
+ # C or F is a degree (30°C).
25
+ RAISED_LETTERS = { "o" => "º", "TM" => "™", "SM" => "℠" }.freeze
26
+ DEGREE_UNIT = /\A\s?[CF]\b/.freeze
27
+ # English and French endings: 21st, 1er, 2nde, XIXe.
28
+ ORDINAL_SUFFIX = /\A(?:st|nd|rd|th|er|ers|re|res|e|es|ème|eme|nde|a|as|os|ª|do|da|ra|ro|ta|to)\z/i.freeze
29
+ NUMBER_END = /(?:\d|\b[IVXLCDM]+)\z/.freeze
30
+ # French titles: Mme, Mlle, Mgr.
31
+ TITLE_SUFFIX = /\A(?:me|mes|lle|lles|gr)\z/.freeze
32
+ PRICE_END = /(?:\p{Sc}|\b[A-Z]{3})\s?\d[\d,.]*\z/.freeze
33
+ DOT_GROUPED = /\d\.\d{3}\z/.freeze
34
+ MATH_BASE = /(?:\d|(?<![\p{L}\p{N}])\p{L})\z/.freeze
35
+ PRICE_START = /\A\s?\p{Sc}/.freeze
36
+ CENTS = /\A\d\d\z/.freeze
37
+ ATTACHED = /[\p{L}\p{N})\]]\z/.freeze
38
+ WORD_START = /\A[\p{L}\p{N}]/.freeze
39
+ WORD_END = /[\p{L}\p{N}]\z/.freeze
40
+
41
+ # Absolute URL worth linking to, or nil for in-page anchors, scripts and
42
+ # phone numbers.
43
+ def self.link_url(href, urls)
44
+ href = href.strip
45
+ return if href.empty? || href.start_with?("#") || href.match?(UNSAFE_SCHEME)
46
+
47
+ url = urls.resolve(href)
48
+ url unless url.nil? || urls.same_page?(url)
49
+ end
50
+
51
+ # redundant_images: RedundantImages#call.
52
+ def initialize(urls, images:, links:, redundant_images: {})
53
+ @urls = urls
54
+ @images = images
55
+ @links = links
56
+ @redundant_images = redundant_images
57
+ @rendered_images = {}
58
+ @emphasis = Hash.new(0)
59
+ @in_heading = false
60
+ @in_link = false
61
+ @link_images = nil
62
+ end
63
+
64
+ # Markup of every image written, for Cleanup to drop redundant images
65
+ # and repeats.
66
+ attr_reader :rendered_images
67
+
68
+ def render(nodes, heading: false)
69
+ outer = @in_heading
70
+ @in_heading ||= heading
71
+ nodes.map { |node| node_text(node) }.join
72
+ ensure
73
+ @in_heading = outer
74
+ end
75
+
76
+ def link_url(href)
77
+ Inline.link_url(href, @urls)
78
+ end
79
+
80
+ private
81
+
82
+ def node_text(node)
83
+ if node.text? || node.cdata?
84
+ Text.escape(Text.collapse(node.content))
85
+ elsif node.element?
86
+ element_text(node)
87
+ else
88
+ ""
89
+ end
90
+ end
91
+
92
+ def element_text(node)
93
+ name = node.name
94
+ return emphasis(node, EMPHASIS[name]) if EMPHASIS.key?(name)
95
+ return code_span(node) if CODE.include?(name)
96
+
97
+ case name
98
+ when "br" then "\n"
99
+ when "wbr" then ""
100
+ when "a" then link(node)
101
+ when "img" then image(node)
102
+ when "q" then "\"#{render(node.children)}\""
103
+ when "math" then math(node)
104
+ when "sup", "sub" then script(node)
105
+ when "input" then node.key?("checked") ? "[x] " : "[ ] "
106
+ else
107
+ text = render(node.children)
108
+ Dom::BLOCK.include?(name) ? " #{text} " : text
109
+ end
110
+ end
111
+
112
+ # Headings are bold already; nested emphasis of the same kind is dropped.
113
+ def emphasis(node, marker)
114
+ return render(node.children) if @emphasis[marker].positive? || (@in_heading && marker == "**")
115
+
116
+ @emphasis[marker] += 1
117
+ content = render(node.children)
118
+ @emphasis[marker] -= 1
119
+ core = content.strip
120
+ return content if core.empty? || core.match?(/\A[^\p{L}\p{N}]*\z/)
121
+
122
+ "#{content[/\A\s*/]}#{marker}#{core}#{marker}#{content[/\s*\z/]}"
123
+ end
124
+
125
+ def code_span(node)
126
+ text = Text.collapse(node.content)
127
+ core = text.strip
128
+ return text.empty? ? "" : " " if core.empty?
129
+
130
+ "#{text[/\A\s*/]}#{Text.code_span(core)}#{text[/\s*\z/]}"
131
+ end
132
+
133
+ # Images inside a link are written before it rather than inside its text.
134
+ def link(node)
135
+ outer_images = @link_images
136
+ outer_link = @in_link
137
+ @link_images = []
138
+ @in_link = true
139
+ content = render(node.children)
140
+ images = @link_images
141
+ @link_images = outer_images
142
+ @in_link = outer_link
143
+
144
+ parts = images + [link_markup(Text.single_line(content), node["href"].to_s)]
145
+ surround_like(content, parts.reject(&:empty?).join(" "))
146
+ end
147
+
148
+ def link_markup(text, href)
149
+ return "" if text.empty? || text.match?(Patterns::LINK_NOISE_TEXT)
150
+ return text if !@links || @in_link
151
+
152
+ url = link_url(href)
153
+ return text unless url
154
+ return url if same_as_url?(text, url)
155
+
156
+ "[#{Text.link_text(text)}](#{Text.destination(url)})"
157
+ end
158
+
159
+ def same_as_url?(text, url)
160
+ bare(text.delete("\\")) == bare(url)
161
+ end
162
+
163
+ def bare(url)
164
+ url.sub(%r{\Ahttps?://}i, "").sub(/\Awww\./i, "").sub(/\Amailto:/i, "").chomp("/")
165
+ end
166
+
167
+ def surround_like(original, rendered)
168
+ return rendered if rendered.empty?
169
+
170
+ leading = original.match?(/\A\s/) ? " " : ""
171
+ trailing = original.match?(/\s\z/) ? " " : ""
172
+ "#{leading}#{rendered}#{trailing}"
173
+ end
174
+
175
+ def image(node)
176
+ alt = Dom.squish(node["alt"].to_s)
177
+ return Image.tex(alt) if node["class"].to_s.include?("mwe-math-fallback-image")
178
+ return alt if Image.emoji?(node, alt)
179
+ return "" if !@images || @in_heading
180
+
181
+ src = Image.source(node, @urls)
182
+ return "" if src.nil? || Image.decorative?(node, src, alt)
183
+
184
+ markup = "![#{Text.link_text(Text.escape(alt))}](#{Text.destination(src)})"
185
+ record(markup, src, node)
186
+ return markup unless @link_images
187
+
188
+ @link_images << markup
189
+ ""
190
+ end
191
+
192
+ # An image is redundant only if every copy of it is. Targets are the
193
+ # pages whose text links make its thumbnail copies redundant.
194
+ def record(markup, src, node)
195
+ image = (@rendered_images[markup] ||= RenderedImage.new(src, true, Set.new))
196
+ key = Dom.key(node)
197
+ image.redundant &&= @redundant_images.key?(key)
198
+ target = @redundant_images[key]
199
+ image.targets << target if target
200
+ end
201
+
202
+ # Markdown has no superscript, and "2attempt" is wrong. Digits and signs
203
+ # have Unicode forms (10⁶, H₂O); other raised and lowered words get a
204
+ # caret or an underscore (x^n, a_n). Footnote markers such as [1],
205
+ # symbols such as ® and small print read fine as plain text. The plain
206
+ # text decides, so 2<sup><i>p</i></sup> is 2^p like 2<sup>p</sup>.
207
+ def script(node)
208
+ text = render(node.children)
209
+ core = Text.collapse(node.content).strip
210
+ return text if core.empty? || core.match?(FOOTNOTE_MARKER) || node.at_css("a, img, math")
211
+
212
+ before = text_before(node) + text[/\A\s*/]
213
+ after = text[/\s*\z/] + adjacent(node, :next_sibling)&.content.to_s
214
+ return nested_script(node, text, core, before, after) if node.at_css("sup, sub")
215
+
216
+ marked = node.name == "sup" ? superscript(core, before, after, text_before(node, skip_blank: true)) : subscript(node, core, before, after)
217
+ marked ? surround_like(text, marked) : unmarked(text, core, before, after)
218
+ end
219
+
220
+ def nested_script(node, text, core, before, after)
221
+ return unmarked(text, core, before, after) unless before.match?(MATH_BASE)
222
+
223
+ surround_like(text, "#{node.name == 'sup' ? '^' : subscript_mark(node, before)}(#{text.strip})")
224
+ end
225
+
226
+ # Glued on, a raised or lowered word would merge with the words around
227
+ # it: Smitha, b for Smith<sup>a, b</sup>, aNote for <sup>a</sup>Note.
228
+ def unmarked(text, core, before, after)
229
+ leading = before.match?(ATTACHED) && core.match?(WORD_START) ? " " : ""
230
+ trailing = core.match?(WORD_END) && after.match?(WORD_START) ? " " : ""
231
+ "#{leading}#{text}#{trailing}"
232
+ end
233
+
234
+ # Text of the node before, with the one before that when it is a bare
235
+ # amount (<sup>$</sup>549<sup>99</sup>).
236
+ def text_before(node, skip_blank: false)
237
+ previous = adjacent(node, :previous_sibling)
238
+ previous = filled(previous) if skip_blank
239
+ before = previous&.content.to_s
240
+ return before unless before.match?(/\A[\d,.]+\z/)
241
+
242
+ earlier = previous.previous_sibling
243
+ earlier = filled(earlier) if skip_blank
244
+ "#{earlier&.content}#{before}"
245
+ end
246
+
247
+ def filled(node)
248
+ node = node.previous_sibling while node&.text? && node.content.strip.empty? && node.previous_sibling
249
+ node
250
+ end
251
+
252
+ # The node right before or after this one, looking past inline wrappers
253
+ # that it opens or closes (21<span><sup>st</sup></span>).
254
+ def adjacent(node, direction)
255
+ node = node.parent while node.public_send(direction).nil? && inline_parent?(node)
256
+ node.public_send(direction)
257
+ end
258
+
259
+ def inline_parent?(node)
260
+ parent = node.parent
261
+ parent&.element? && !Dom::BLOCK.include?(parent.name)
262
+ end
263
+
264
+ # Cents and ordinals are raised only for looks: $549.99, 21st, XIXe. A
265
+ # badge after a space (Feature <sup>NEW</sup>) is not an exponent.
266
+ def superscript(core, before, after, amount)
267
+ return cents(core, amount) if core.match?(CENTS) && price?(amount, after)
268
+ return core.tr(SCRIPT_SOURCE, SUPERSCRIPTS) if core.match?(SCRIPT_TEXT)
269
+ return core if plain_suffix?(core, before)
270
+
271
+ raised_letter(core, after) || ("^#{grouped(core, after)}" if expression?(core) && math_base?(core, before))
272
+ end
273
+
274
+ def math_base?(core, before)
275
+ before.match?(MATH_BASE) || (before.match?(/\)\z/) && core.match?(/[\d+\-−=\/]/))
276
+ end
277
+
278
+ # A lowered word is an index only when it hangs off the word before it.
279
+ def subscript(node, core, before, after)
280
+ return core.tr(SCRIPT_SOURCE, SUBSCRIPTS) if core.match?(SCRIPT_TEXT)
281
+
282
+ return unless expression?(core) && before.match?(ATTACHED)
283
+
284
+ "#{subscript_mark(node, before)}#{grouped(core, after)}"
285
+ end
286
+
287
+ def subscript_mark(node, before)
288
+ previous = adjacent(node, :previous_sibling)
289
+ marked = previous&.element? && (EMPHASIS.key?(previous.name) || CODE.include?(previous.name))
290
+ before.match?(WORD_END) && !marked ? "_" : "\\_"
291
+ end
292
+
293
+ # $549<sup>99</sup>, <sup>$</sup>549<sup>99</sup> and 549<sup>99</sup> €.
294
+ def price?(before, after)
295
+ before.match?(PRICE_END) || (before.match?(/\d,?\z/) && after.match?(PRICE_START))
296
+ end
297
+
298
+ # €12,<sup>99</sup> has its decimal comma already.
299
+ def cents(core, before)
300
+ return core if before.end_with?(",")
301
+
302
+ before.match?(DOT_GROUPED) ? ",#{core}" : ".#{core}"
303
+ end
304
+
305
+ def plain_suffix?(core, before)
306
+ (core.match?(ORDINAL_SUFFIX) && before.match?(NUMBER_END)) || (core.match?(TITLE_SUFFIX) && before.match?(/\bM\z/))
307
+ end
308
+
309
+ def raised_letter(core, after)
310
+ return "°" if core == "o" && after.match?(DEGREE_UNIT)
311
+
312
+ RAISED_LETTERS[core] || RAISED_LETTERS[core.upcase]
313
+ end
314
+
315
+ # Spaces count only around operators (n - 1), so small print is not an
316
+ # expression.
317
+ def expression?(core)
318
+ compact = core.delete(" ")
319
+ compact.match?(SCRIPT_WORD) && (compact == core || compact.match?(SCRIPT_OPERATOR))
320
+ end
321
+
322
+ # 2^n-1 would read as 2^n minus 1, 2^2n as 2^2 times n, e^(a)(b) as
323
+ # e^(a) times (b), and x_iy_i as x with the index iy_i.
324
+ def grouped(core, after)
325
+ return core if core.match?(GROUPED)
326
+ return core if (core.length == 1 || core.match?(/\A\p{L}+\z/)) && !after.match?(WORD_START)
327
+
328
+ "(#{core})"
329
+ end
330
+
331
+ def math(node)
332
+ annotation = node.css("annotation").find { |a| a["encoding"].to_s.include?("tex") }
333
+ tex = (annotation&.content || node["alttext"]).to_s.strip
334
+ return Text.escape(Text.collapse(node.content).strip) if tex.empty?
335
+
336
+ node["display"] == "block" ? "$$#{tex}$$" : "$#{tex}$"
337
+ end
338
+ end
339
+ end
340
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ # Finds images that cost tokens without telling the reader anything: card
6
+ # thumbnails beside a text link to the same page, and images the page
7
+ # marks as decorative. They are still rendered, and Cleanup removes them
8
+ # last, so a heading whose section held only an image keeps its place.
9
+ class RedundantImages
10
+ MONTHS = "jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|" \
11
+ "aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?"
12
+ # A squashed label that is only a date: "Oct 1, 2026", "1 October".
13
+ DATE = /\A(?:\d+(?:#{MONTHS})\d*|(?:#{MONTHS})\d+)\z/.freeze
14
+ CONTENT_SIZE = 300
15
+ LABEL_SHARE = 0.3
16
+
17
+ # Where a link destination points, ignoring the fragment and a trailing
18
+ # slash. ImageCleanup reads the links left in the Markdown this way.
19
+ def self.page(destination)
20
+ destination.sub(/#.*\z/m, "").chomp("/")
21
+ end
22
+
23
+ def initialize(nodes, mode, urls)
24
+ @nodes = nodes
25
+ @mode = mode
26
+ @urls = urls
27
+ @text_free = {}
28
+ end
29
+
30
+ # Dom keys of the images to drop. A thumbnail maps to the page whose
31
+ # text link names it, a decorative image to nil.
32
+ def call
33
+ links = elements("a").map { |anchor| [anchor, target(anchor)] }.select { |_, page| page }
34
+ labels = link_labels(links)
35
+ redundant = {}
36
+ links.each do |anchor, page|
37
+ thumbnails(anchor, labels[page]).each { |img| redundant[Dom.key(img)] = page }
38
+ end
39
+ elements("img").each { |img| redundant[Dom.key(img)] = nil if decorative?(img) }
40
+ redundant.reject { |key, _| key.nil? }
41
+ end
42
+
43
+ private
44
+
45
+ def elements(name)
46
+ @nodes.flat_map { |node| (node.name == name ? [node] : []) + node.css(name).to_a }
47
+ end
48
+
49
+ # Link texts by page, squashed for comparison with alt text. "Read
50
+ # more" is not written to the Markdown, and "3 comments" names nothing.
51
+ def link_labels(links)
52
+ links.each_with_object({}) do |(anchor, page), labels|
53
+ text = Dom.text(anchor)
54
+ next if text.match?(Patterns::LINK_NOISE_TEXT) || text.match?(Patterns::BOILERPLATE_TEXT)
55
+
56
+ label = squash(text)
57
+ (labels[page] ||= []) << label unless label.empty?
58
+ end
59
+ end
60
+
61
+ # A link with its own text, or another link to the same page with text,
62
+ # names the destination already. A link to an image file is a zoom, not
63
+ # a card.
64
+ def thumbnails(anchor, labels)
65
+ return [] if labels.nil? || image_link?(anchor)
66
+ return [] if @mode != :page && Dom.ancestors_named(anchor, "figure").any?
67
+
68
+ anchor.css("img").select { |img| adds_nothing?(Dom.squish(img["alt"].to_s), labels) }
69
+ end
70
+
71
+ # An image without alt text adds nothing to any label. One with an alt
72
+ # needs a label in words, since a price or a date does not say what the
73
+ # page is; page mode then drops it, article mode only if the alt
74
+ # repeats the label.
75
+ def adds_nothing?(alt, labels)
76
+ return true if alt.empty?
77
+
78
+ names = labels.select { |label| label.match?(/\p{L}/) && !label.match?(DATE) }
79
+ !names.empty? && (@mode == :page || repeats?(alt, names))
80
+ end
81
+
82
+ # An alt of a word or two ("Photo", "Boot") adds nothing either.
83
+ def repeats?(alt, names)
84
+ return true if alt.split.size <= 2
85
+
86
+ alt = squash(alt)
87
+ names.any? { |name| name.include?(alt) || (alt.include?(name) && name.length >= alt.length * LABEL_SHARE) }
88
+ end
89
+
90
+ # The page a link opens, written as the Markdown link would be.
91
+ def target(anchor)
92
+ url = Inline.link_url(anchor["href"].to_s, @urls)
93
+ RedundantImages.page(Text.destination(url)) if url
94
+ end
95
+
96
+ # alt="" and role="presentation" are how HTML marks an image as
97
+ # decorative, but many sites write alt="" on every image. Articles
98
+ # carry their photos in any wrapper, so there only the role counts; on
99
+ # other pages, where an image sits can still make it content. A
100
+ # missing alt says nothing.
101
+ def decorative?(img)
102
+ presentation = %w[presentation none].include?(img["role"].to_s.strip.downcase)
103
+ return false unless presentation || (@mode == :page && img.key?("alt") && Dom.squish(img["alt"]).empty?)
104
+
105
+ !content_sized?(img) && !placed_as_content?(img)
106
+ end
107
+
108
+ def content_sized?(img)
109
+ [img["width"], img["height"]].any? { |value| Image.pixels(value).to_i >= CONTENT_SIZE }
110
+ end
111
+
112
+ # In a figure, linked to its full-size file, or in a paragraph or table
113
+ # cell without text (a tick in a comparison table).
114
+ def placed_as_content?(img)
115
+ ancestors = Dom.ancestors_named(img, "figure", "a", "p", "td", "th")
116
+ return true if ancestors.any? { |node| node.name == "figure" || image_link?(node) }
117
+
118
+ holder = ancestors.find { |node| node.name != "a" }
119
+ !holder.nil? && text_free?(holder)
120
+ end
121
+
122
+ # Memoized: on a table layout one cell holds the whole page and all of
123
+ # its images.
124
+ def text_free?(node)
125
+ key = Dom.key(node)
126
+ return @text_free[key] if key && @text_free.key?(key)
127
+
128
+ free = Dom.text(node).empty?
129
+ @text_free[key] = free if key
130
+ free
131
+ end
132
+
133
+ def image_link?(node)
134
+ node.name == "a" && node["href"].to_s.match?(Preprocessor::IMAGE_URL)
135
+ end
136
+
137
+ def squash(text)
138
+ text.downcase.gsub(/[^\p{L}\p{N}]+/, "")
139
+ end
140
+ end
141
+ end
142
+ end