datalog-theme 0.10.0 → 0.11.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +55 -3
  6. data/_data/i18n/es.yml +55 -3
  7. data/_data/i18n/pt.yml +55 -3
  8. data/_includes/analytics/dashboard.html +24 -9
  9. data/_includes/components/academic-dashboard.html +168 -133
  10. data/_includes/components/author-bio.html +7 -5
  11. data/_includes/components/author-list.html +6 -5
  12. data/_includes/components/citation-tools.html +2 -1
  13. data/_includes/components/content-provenance.html +19 -1
  14. data/_includes/components/correction-report.html +15 -8
  15. data/_includes/components/hero.html +22 -15
  16. data/_includes/components/package-index.html +121 -0
  17. data/_includes/components/package-install.html +27 -50
  18. data/_includes/components/post-list.html +14 -0
  19. data/_includes/components/responsive-image.html +15 -21
  20. data/_includes/footer.html +1 -1
  21. data/_includes/head.html +70 -11
  22. data/_includes/layouts/default/article.html +76 -46
  23. data/_includes/meta/dataset-json.html +171 -0
  24. data/_includes/meta/math-config.html +15 -1
  25. data/_includes/meta/package-json.html +69 -0
  26. data/_includes/meta/person-json.html +36 -13
  27. data/_includes/meta/publisher.html +15 -0
  28. data/_includes/meta/schema.html +108 -23
  29. data/_includes/meta/scholarly.html +10 -5
  30. data/_includes/post/related-posts.html +17 -43
  31. data/_includes/scripts.html +2 -2
  32. data/_includes/search/index-data.json +10 -1
  33. data/_includes/search/page.html +11 -6
  34. data/_layouts/archive.html +67 -0
  35. data/_layouts/dataset.html +2 -1
  36. data/_layouts/default.html +13 -11
  37. data/_layouts/docs.html +53 -0
  38. data/_layouts/home.html +30 -1
  39. data/_layouts/notebook.html +5 -1
  40. data/_layouts/package.html +38 -21
  41. data/_layouts/portfolio.html +1 -0
  42. data/_layouts/post.html +21 -62
  43. data/_layouts/project.html +2 -1
  44. data/_layouts/research.html +14 -2
  45. data/_plugins/analytics_dashboard.rb +4 -1
  46. data/_plugins/archive.rb +114 -0
  47. data/_plugins/authors.rb +50 -1
  48. data/_plugins/citation_exports.rb +12 -1
  49. data/_plugins/config_validator.rb +77 -2
  50. data/_plugins/correction_fallback.rb +110 -0
  51. data/_plugins/image_optimizer.rb +129 -22
  52. data/_plugins/math_preprocessor.rb +7 -48
  53. data/_plugins/notebook_converter.rb +44 -5
  54. data/_plugins/packages.rb +84 -0
  55. data/_plugins/page_dates.rb +37 -0
  56. data/_plugins/references.rb +16 -2
  57. data/_plugins/related_posts.rb +149 -0
  58. data/_plugins/search_sections.rb +110 -0
  59. data/_plugins/site_identity.rb +42 -0
  60. data/_plugins/social_cards.rb +17 -0
  61. data/_sass/_academic-dashboard.scss +18 -30
  62. data/_sass/_base.scss +2 -0
  63. data/_sass/_components.scss +21 -23
  64. data/_sass/_docs.scss +143 -0
  65. data/_sass/_features.scss +6 -0
  66. data/_sass/_layout.scss +65 -6
  67. data/_sass/_mathematical.scss +61 -19
  68. data/_sass/_notebooks.scss +1 -32
  69. data/_sass/_package-docs.scss +6 -9
  70. data/_sass/_post-components.scss +23 -23
  71. data/_sass/_print.scss +1 -1
  72. data/_sass/_search-page.scss +75 -0
  73. data/_sass/_search.scss +17 -26
  74. data/_sass/_syntax-highlighting.scss +23 -1
  75. data/_sass/_theme.scss +1 -0
  76. data/_sass/_utilities.scss +87 -40
  77. data/_sass/_variables.scss +31 -29
  78. data/assets/css/main.scss +2 -1
  79. data/assets/js/dist/academic.js +1 -1
  80. data/assets/js/dist/analytics-dashboard.js +1 -1
  81. data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
  82. data/assets/js/dist/comments.js +1 -1
  83. data/assets/js/dist/contact.js +1 -1
  84. data/assets/js/dist/core.js +1 -1
  85. data/assets/js/dist/corrections.js +5 -1
  86. data/assets/js/dist/loader.js +1 -1
  87. data/assets/js/dist/math.js +2 -1
  88. data/assets/js/dist/moderation.js +1 -1
  89. data/assets/js/dist/reactions.js +1 -1
  90. data/assets/js/dist/search.js +1 -1
  91. data/assets/js/dist/sources.json +20 -16
  92. data/assets/js/dist/subscriptions.js +1 -1
  93. data/assets/js/loader.js +6 -2
  94. data/datalog-theme.gemspec +1 -1
  95. data/lib/datalog/audit/checks.rb +269 -0
  96. data/lib/datalog/audit/known_keys.rb +61 -0
  97. data/lib/datalog/audit/site_reader.rb +118 -0
  98. data/lib/datalog/audit/source_file.rb +85 -0
  99. data/lib/datalog/audit.rb +145 -0
  100. data/lib/datalog/citations/bibtex.rb +200 -0
  101. data/lib/datalog/citations/entry.rb +333 -0
  102. data/lib/datalog/citations/markup.rb +79 -0
  103. data/lib/datalog/cli.rb +164 -89
  104. data/lib/datalog/critical_css.rb +126 -21
  105. data/lib/datalog/latex_speech/words.json +88 -0
  106. data/lib/datalog/latex_speech.rb +355 -0
  107. data/lib/datalog/packages/command.rb +51 -0
  108. data/lib/datalog/packages/refresh.rb +187 -0
  109. data/lib/datalog/packages.rb +177 -0
  110. data/lib/datalog/plugin_system.rb +5 -0
  111. data/lib/datalog/plugins/citations.rb +325 -109
  112. data/lib/datalog/site_config.rb +26 -0
  113. data/lib/datalog/social_cards/font.rb +286 -0
  114. data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
  115. data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
  116. data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
  117. data/lib/datalog/social_cards/geometry.rb +51 -0
  118. data/lib/datalog/social_cards/outline.rb +54 -0
  119. data/lib/datalog/social_cards/plain_text.rb +258 -0
  120. data/lib/datalog/social_cards/template.rb +322 -0
  121. data/lib/datalog/social_cards/template.svg +17 -0
  122. data/lib/datalog/social_cards/typesetter.rb +178 -0
  123. data/lib/datalog/social_cards.rb +369 -0
  124. data/lib/datalog/theme/updater.rb +307 -0
  125. data/lib/datalog/theme/version.rb +1 -1
  126. metadata +46 -9
  127. data/_includes/components/enhanced-code-block.html +0 -212
  128. data/_includes/components/performance-monitor.html +0 -170
  129. data/_includes/components/viz-table-fallback.html +0 -19
  130. data/_plugins/datalog_bibliography.rb +0 -21
  131. data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +0 -1
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ module SocialCards
5
+ # A title's TeX as readable text: "$\alpha$-stable laws for $X_t^2$"
6
+ # becomes "α-stable laws for X_t²". A share card is an image, so nothing
7
+ # typesets the maths on it; what is left has to read on its own.
8
+ #
9
+ # Inline maths is $...$ or \(...\), display maths $$...$$ or \[...\], by
10
+ # the rules the math preprocessor follows: a dollar sign with a space
11
+ # after it opens maths only when what follows holds a command, ^ or _
12
+ # (`$ \frac{a}{b} $`), and a digit after the closing one makes it money
13
+ # (`$5 and $10`). Outside maths only the escaped characters change.
14
+ module PlainText
15
+ module_function
16
+
17
+ GREEK = %w[
18
+ alpha α beta β gamma γ delta δ epsilon ε varepsilon ε zeta ζ eta η theta θ vartheta ϑ iota ι kappa κ
19
+ lambda λ mu μ nu ν xi ξ pi π varpi ϖ rho ρ varrho ϱ sigma σ varsigma ς tau τ upsilon υ phi φ varphi φ
20
+ chi χ psi ψ omega ω Gamma Γ Delta Δ Theta Θ Lambda Λ Xi Ξ Pi Π Sigma Σ Upsilon Υ Phi Φ Psi Ψ Omega Ω
21
+ ].each_slice(2).to_h.freeze
22
+
23
+ SYMBOLS = {
24
+ "leq" => "≤", "le" => "≤", "geq" => "≥", "ge" => "≥", "neq" => "≠", "ne" => "≠", "approx" => "≈",
25
+ "sim" => "~", "simeq" => "≃", "equiv" => "≡", "propto" => "∝", "infty" => "∞", "in" => "∈",
26
+ "notin" => "∉", "subset" => "⊂", "subseteq" => "⊆", "cup" => "∪", "cap" => "∩", "times" => "×",
27
+ "cdot" => "·", "pm" => "±", "mp" => "∓", "div" => "÷", "to" => "→", "rightarrow" => "→",
28
+ "leftarrow" => "←", "leftrightarrow" => "↔", "Rightarrow" => "⇒", "implies" => "⇒", "iff" => "⇔",
29
+ "mapsto" => "↦", "sum" => "∑", "prod" => "∏", "int" => "∫", "oint" => "∮", "partial" => "∂",
30
+ "nabla" => "∇", "forall" => "∀", "exists" => "∃", "emptyset" => "∅", "varnothing" => "∅",
31
+ "ldots" => "…", "dots" => "…", "cdots" => "⋯", "prime" => "′", "ell" => "ℓ", "circ" => "∘",
32
+ "langle" => "⟨", "rangle" => "⟩", "mid" => "|", "vert" => "|", "Vert" => "‖", "|" => "‖",
33
+ "lbrace" => "{", "rbrace" => "}", "{" => "{", "}" => "}", "%" => "%", "$" => "$", "&" => "&",
34
+ "#" => "#", "_" => "_", "neg" => "¬", "land" => "∧", "lor" => "∨", "star" => "⋆", "ast" => "∗",
35
+ "degree" => "°", "top" => "T", "intercal" => "T",
36
+ "," => " ", ";" => " ", ":" => " ", " " => " ", "quad" => " ", "qquad" => " ",
37
+ "!" => "", "\\" => " "
38
+ }.freeze
39
+
40
+ DOUBLE_STRUCK = { "R" => "ℝ", "N" => "ℕ", "Z" => "ℤ", "Q" => "ℚ", "C" => "ℂ", "P" => "ℙ", "E" => "𝔼" }.freeze
41
+
42
+ # Commands whose one argument is all that is left of them.
43
+ KEEP_ARGUMENT = %w[
44
+ text textrm textit textbf textsf texttt mathrm mathbf mathit mathsf mathtt mathcal mathscr mathfrak
45
+ boldsymbol bm operatorname emph mbox hbox displaystyle textstyle
46
+ ].freeze
47
+
48
+ ACCENTS = {
49
+ "hat" => "̂", "widehat" => "̂", "bar" => "̄", "overline" => "̅",
50
+ "tilde" => "̃", "widetilde" => "̃", "dot" => "̇", "ddot" => "̈", "vec" => "⃗"
51
+ }.freeze
52
+
53
+ # Sizing and spacing that has nothing to say in text.
54
+ DROP = %w[left right big Big bigg Bigg bigl bigr Bigl Bigr limits nolimits displaystyle textstyle].freeze
55
+
56
+ SUPERSCRIPTS = "0123456789".chars.zip("⁰¹²³⁴⁵⁶⁷⁸⁹".chars).to_h.freeze
57
+ SUBSCRIPTS = "0123456789".chars.zip("₀₁₂₃₄₅₆₇₈₉".chars).to_h.freeze
58
+
59
+ def from_tex(text)
60
+ out = +""
61
+ source = text.to_s
62
+ scanner = Scanner.new(source)
63
+ index = 0
64
+ while index < source.length
65
+ math, length = scanner.math_at(index)
66
+ if math
67
+ out << math_to_text(math)
68
+ index += length
69
+ else
70
+ out << plain_char(source, index)
71
+ index += source[index] == "\\" && "$%&#_{}".include?(source[index + 1].to_s) ? 2 : 1
72
+ end
73
+ end
74
+ out.unicode_normalize(:nfc).gsub(/\s+/, " ").strip
75
+ end
76
+
77
+ SPACED_MATH = /\\[a-zA-Z]+|[\^_]/
78
+ DELIMITERS = { "$$" => "$$", "\\[" => "\\]", "\\(" => "\\)" }.freeze
79
+
80
+ # Finds where maths opens and closes in one title. The dollar signs that
81
+ # can close maths are found once, and a delimiter that never closes is
82
+ # remembered, so a title of many unclosed openers costs no more than
83
+ # one read of it.
84
+ class Scanner
85
+ def initialize(source)
86
+ @source = source
87
+ @unclosed = {}
88
+ @closers = closers
89
+ @tight_closers = @closers.select { |at| @source[at - 1].match?(/\S/) }
90
+ end
91
+
92
+ # [inner TeX, length taken] when maths opens at the index, else nil.
93
+ def math_at(index)
94
+ opener = @source[index, 2]
95
+ return delimited(index, opener) if DELIMITERS.key?(opener)
96
+ return unless @source[index] == "$" && (index.zero? || @source[index - 1] != "\\")
97
+
98
+ spaced = @source[index + 1].to_s.match?(/\s/)
99
+ close = (spaced ? @closers : @tight_closers).bsearch { |at| at >= index + 2 }
100
+ body = close && @source[(index + 1)...close]
101
+ return unless body && (!spaced || body.match?(SPACED_MATH))
102
+
103
+ [body, close + 1 - index]
104
+ end
105
+
106
+ private
107
+
108
+ def delimited(index, opener)
109
+ return if @unclosed[opener]
110
+
111
+ close = @source.index(DELIMITERS[opener], index + 2)
112
+ return [@source[(index + 2)...close], close + 2 - index] if close
113
+
114
+ @unclosed[opener] = true
115
+ nil
116
+ end
117
+
118
+ # Each dollar sign that can close inline maths: unescaped, not one of
119
+ # a pair, and not followed by a digit (`$5 and $10` is money).
120
+ def closers
121
+ found = []
122
+ at = -1
123
+ while (at = @source.index("$", at + 1))
124
+ before = @source[at - 1] if at.positive?
125
+ after = @source[at + 1].to_s
126
+ found << at unless at.zero? || ["\\", "$"].include?(before) || after == "$" || after.match?(/\d/)
127
+ end
128
+ found
129
+ end
130
+ end
131
+
132
+ def plain_char(source, index)
133
+ char = source[index]
134
+ return char unless char == "\\"
135
+
136
+ following = source[index + 1].to_s
137
+ "$%&#_{}".include?(following) && !following.empty? ? following : char
138
+ end
139
+
140
+ # Relations stand between spaces, as TeX sets them: "x ≤ y", not "x≤y".
141
+ # The spaces around them are collapsed afterwards, so the pattern needs
142
+ # no `\s*` of its own, which would take quadratic time on a run of them.
143
+ RELATIONS = /[=<>≤≥≠≈≃≡∝∈∉⊂⊆→←↔⇒⇔↦]/
144
+
145
+ def math_to_text(tex)
146
+ Reader.new(tex).read.gsub(RELATIONS) { " #{Regexp.last_match(0)} " }.gsub(/\s+/, " ").strip
147
+ end
148
+
149
+ # A recursive reading of a maths expression: commands, their arguments,
150
+ # groups, and the superscripts and subscripts after any of them.
151
+ class Reader
152
+ def initialize(tex)
153
+ @tex = tex
154
+ @at = 0
155
+ end
156
+
157
+ def read(stop = nil)
158
+ out = +""
159
+ while @at < @tex.length
160
+ char = @tex[@at]
161
+ break if stop && char == stop
162
+
163
+ out << case char
164
+ when "\\" then command
165
+ when "{" then group
166
+ when "^" then script(PlainText::SUPERSCRIPTS, "^")
167
+ when "_" then script(PlainText::SUBSCRIPTS, "_")
168
+ when "}" then skip("")
169
+ when "-" then skip("−")
170
+ when "~" then skip(" ")
171
+ when "'" then skip("′")
172
+ else skip(char)
173
+ end
174
+ end
175
+ out
176
+ end
177
+
178
+ private
179
+
180
+ def skip(text)
181
+ @at += 1
182
+ text
183
+ end
184
+
185
+ def group
186
+ @at += 1
187
+ inner = read("}")
188
+ @at += 1
189
+ inner
190
+ end
191
+
192
+ # The next argument: a group, a command, or one character.
193
+ def argument
194
+ @at += 1 while @tex[@at] == " "
195
+ return "" if @at >= @tex.length
196
+
197
+ case @tex[@at]
198
+ when "{" then group
199
+ when "\\" then command
200
+ else skip(@tex[@at])
201
+ end
202
+ end
203
+
204
+ def command
205
+ @at += 1
206
+ stop = @tex.index(/[^A-Za-z]/, @at) || @tex.length
207
+ name = stop > @at ? @tex[@at...stop] : nil
208
+ if name.nil?
209
+ symbol = @tex[@at].to_s
210
+ @at += 1
211
+ return PlainText::SYMBOLS.fetch(symbol, symbol)
212
+ end
213
+ @at += name.length
214
+ @at += 1 while @tex[@at] == " "
215
+ named(name)
216
+ end
217
+
218
+ def named(name)
219
+ return "" if PlainText::DROP.include?(name)
220
+ return PlainText::GREEK[name] if PlainText::GREEK.key?(name)
221
+ return PlainText::SYMBOLS[name] if PlainText::SYMBOLS.key?(name)
222
+ return argument if PlainText::KEEP_ARGUMENT.include?(name)
223
+ return argument.chars.map { |char| PlainText::DOUBLE_STRUCK.fetch(char, char) }.join if name == "mathbb"
224
+ return "#{argument}#{PlainText::ACCENTS[name]}" if PlainText::ACCENTS.key?(name)
225
+
226
+ case name
227
+ when "frac", "dfrac", "tfrac" then "#{wrap(argument)}/#{wrap(argument)}"
228
+ when "sqrt"
229
+ optional
230
+ "√#{wrap(argument)}"
231
+ else name # \log, \max, \exp and the rest read as their names
232
+ end
233
+ end
234
+
235
+ def optional
236
+ return unless @tex[@at] == "["
237
+
238
+ close = @tex.index("]", @at)
239
+ @at = close ? close + 1 : @tex.length
240
+ end
241
+
242
+ def script(table, mark)
243
+ @at += 1
244
+ inner = argument.strip
245
+ return "" if inner.empty?
246
+ return inner.chars.map { |char| table[char] }.join if inner.chars.all? { |char| table.key?(char) }
247
+
248
+ inner.length == 1 ? "#{mark}#{inner}" : "#{mark}(#{inner})"
249
+ end
250
+
251
+ def wrap(text)
252
+ text = text.strip
253
+ text.match?(/\A[\p{L}\p{N}.′]+\z/) ? text : "(#{text})"
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fastimage"
4
+ require "nokogiri"
5
+ require_relative "geometry"
6
+ require_relative "typesetter"
7
+
8
+ module Datalog
9
+ module SocialCards
10
+ # A card's SVG template, drawn as MVG, ImageMagick's own vector format.
11
+ # Every ImageMagick reads MVG; SVG it reads only with a renderer that a
12
+ # minimal install leaves out (Ubuntu's `imagemagick` without its
13
+ # recommended packages reads no SVG at all). So the template is SVG, to
14
+ # write and preview in anything, and this draws the part of SVG a card
15
+ # needs:
16
+ #
17
+ # rect, circle, ellipse, line, polyline, polygon, path, image and g;
18
+ # fill, stroke, stroke-width, opacity, fill-opacity, stroke-opacity,
19
+ # stroke-linecap, stroke-linejoin and fill-rule, as attributes or in
20
+ # `style`; transform (translate, scale, rotate, matrix).
21
+ #
22
+ # Gradients, filters, masks, `use` and plain <text> are not drawn. Text
23
+ # goes in slots, each a <text> with `data-field` and no content, which
24
+ # are set in the theme's fonts and drawn as outlines:
25
+ #
26
+ # <text data-field="title" x="80" y="220" width="1040" height="280"
27
+ # font-size="68" data-min-font-size="40" data-max-lines="4"
28
+ # data-font="serif" data-line-height="1.15" fill="{{ink}}"/>
29
+ #
30
+ # `y` is the top of the box, not a baseline. A <rect data-field="logo">
31
+ # is where the logo goes. {{background}}, {{ink}}, {{muted}} and
32
+ # {{accent}} are the scheme's colours.
33
+ class Template
34
+ include Geometry
35
+
36
+ class Error < StandardError; end
37
+
38
+ WIDTH = 1200
39
+ HEIGHT = 630
40
+ DEFAULT = File.expand_path("template.svg", __dir__)
41
+ FIELDS = %w[site kicker title byline detail].freeze
42
+ DRAWN = %w[rect circle ellipse line polyline polygon path image].freeze
43
+ SKIPPED = %w[title desc metadata defs style].freeze
44
+ STYLE = %w[fill stroke stroke-width opacity fill-opacity stroke-opacity stroke-linecap stroke-linejoin
45
+ fill-rule].freeze
46
+ COLOR = /\A(?:#\h{3,8}|[a-z]+|rgba?\(\s*[\d.]+%?(?:\s*,\s*[\d.]+%?){2,3}\s*\))\z/i
47
+ PATH_DATA = /\A[MmLlHhVvCcSsQqTtAaZz0-9eE.,+\-\s]*\z/
48
+
49
+ # SVG's initial values: what an element with no style of its own, or of
50
+ # the <svg> around it, is drawn with.
51
+ BASE_STYLE = { "fill" => "black", "opacity" => 1.0 }.freeze
52
+
53
+ attr_reader :ignored
54
+
55
+ # `source` is the SVG text; `resolve` turns an image's href into a file.
56
+ def initialize(source, colors:, resolve: ->(_href) {})
57
+ filled = source.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) { colors.fetch(Regexp.last_match(1), Regexp.last_match(0)) }
58
+ @document = Nokogiri::XML(filled, &:strict)
59
+ @colors = colors
60
+ @resolve = resolve
61
+ @ignored = []
62
+ rescue Nokogiri::XML::SyntaxError => e
63
+ raise Error, "the card template is not well-formed SVG (#{e.message.strip})"
64
+ end
65
+
66
+ # The card as MVG. `fields` maps a slot's name to its text; `logo` is
67
+ # nil, an SVG string, or the path of an image file.
68
+ def to_mvg(fields, logo: nil)
69
+ root = @document.root
70
+ raise Error, "the card template's root element is not <svg>" unless root&.name == "svg"
71
+
72
+ @fields = fields
73
+ @logo = logo
74
+ @out = ["viewbox 0 0 #{WIDTH} #{HEIGHT}"]
75
+ walk(root, root_matrix(root), inherit(BASE_STYLE, root))
76
+ @out.join("\n") << "\n"
77
+ end
78
+
79
+ # This SVG drawn into a box of the card, as MVG lines: a logo.
80
+ def fitted(matrix, width, height)
81
+ @fields = {}
82
+ @logo = nil
83
+ @out = []
84
+ walk(@document.root, multiply(matrix, fitted_matrix(width, height)), inherit(BASE_STYLE, @document.root))
85
+ @out
86
+ end
87
+
88
+ private
89
+
90
+ def root_matrix(root)
91
+ view = root["viewBox"].to_s.scan(NUMBER).map(&:to_f)
92
+ view = [0.0, 0.0, length(root["width"], WIDTH), length(root["height"], HEIGHT)] unless view.size == 4
93
+ sx = WIDTH / view[2]
94
+ sy = HEIGHT / view[3]
95
+ [sx, 0.0, 0.0, sy, -view[0] * sx, -view[1] * sy]
96
+ end
97
+
98
+ def walk(node, matrix, style)
99
+ node.element_children.each do |child|
100
+ next if SKIPPED.include?(child.name)
101
+
102
+ own = multiply(matrix, transform(child["transform"]))
103
+ inherited = inherit(style, child)
104
+ case child.name
105
+ when "g", "svg", "a" then walk(child, own, inherited)
106
+ when "text" then slot(child, own, inherited)
107
+ when *DRAWN then draw(child, own, inherited)
108
+ else @ignored << child.name
109
+ end
110
+ end
111
+ end
112
+
113
+ def inherit(style, node)
114
+ own = STYLE.to_h { |name| [name, node[name]] }.compact
115
+ node["style"].to_s.split(";").each do |declaration|
116
+ name, value = declaration.split(":", 2).map(&:strip)
117
+ own[name] = value if STYLE.include?(name) && value
118
+ end
119
+ merged = style.merge(own)
120
+ merged["opacity"] = style.fetch("opacity", 1.0) * number(own["opacity"], 1.0)
121
+ merged
122
+ end
123
+
124
+ def draw(node, matrix, style)
125
+ return logo(node, matrix) if node["data-field"] == "logo"
126
+
127
+ primitive = primitive(node)
128
+ emit(matrix, style, primitive) if primitive
129
+ end
130
+
131
+ def primitive(node)
132
+ n = ->(name, fallback = 0) { number(node[name], fallback) }
133
+ case node.name
134
+ when "rect" then rectangle(box(node), n["rx", n["ry"]], n["ry", n["rx"]])
135
+ when "circle" then "circle #{pair(n['cx'], n['cy'])} #{pair(n['cx'], n['cy'] + n['r'])}"
136
+ when "ellipse" then "ellipse #{pair(n['cx'], n['cy'])} #{pair(n['rx'], n['ry'])} 0,360"
137
+ when "line" then "line #{pair(n['x1'], n['y1'])} #{pair(n['x2'], n['y2'])}"
138
+ when "polyline", "polygon" then points(node)
139
+ when "path" then path_data(node["d"])
140
+ when "image" then image(node)
141
+ end
142
+ end
143
+
144
+ def rectangle(box, radius_x, radius_y)
145
+ left, top, width, height = box
146
+ return if width <= 0 || height <= 0
147
+
148
+ corners = "#{pair(left, top)} #{pair(left + width, top + height)}"
149
+ return "rectangle #{corners}" unless radius_x.positive? || radius_y.positive?
150
+
151
+ "roundrectangle #{corners} #{pair(radius_x, radius_y)}"
152
+ end
153
+
154
+ # [x, y, width, height] of an element.
155
+ def box(node)
156
+ %w[x y width height].map { |name| number(node[name]) }
157
+ end
158
+
159
+ def points(node)
160
+ values = node["points"].to_s.scan(NUMBER).map(&:to_f)
161
+ return if values.size < 4
162
+
163
+ "#{node.name} #{values.each_slice(2).map { |x, y| pair(x, y.to_f) }.join(' ')}"
164
+ end
165
+
166
+ def path_data(data)
167
+ data = data.to_s.strip
168
+ return if data.empty?
169
+ unless data.match?(PATH_DATA)
170
+ raise Error, "a path in the card template has data that is not path data: #{data[0, 40]}"
171
+ end
172
+
173
+ "path '#{data}'"
174
+ end
175
+
176
+ def image(node)
177
+ href = node["href"] || node["xlink:href"]
178
+ file = @resolve.call(href)
179
+ unless file
180
+ @ignored << "image #{href}"
181
+ return
182
+ end
183
+ raise Error, "the image #{file} has a quote in its path" if file.include?("'")
184
+
185
+ size = pair(number(node["width"]), number(node["height"]))
186
+ "image over #{pair(number(node['x']), number(node['y']))} #{size} '#{file}'"
187
+ end
188
+
189
+ def emit(matrix, style, *primitives)
190
+ @out << "push graphic-context"
191
+ @out << "affine #{matrix.map { |value| format_number(value) }.join(',')}"
192
+ @out.concat(style_commands(style))
193
+ @out.concat(primitives)
194
+ @out << "pop graphic-context"
195
+ end
196
+
197
+ # An opacity is written only for a colour that is painted, and only
198
+ # below 1: ImageMagick 7 reads `stroke-opacity 1` after `stroke none` as
199
+ # an opaque black stroke, which thinned every light glyph on a dark card.
200
+ def style_commands(style)
201
+ opacity = style.fetch("opacity", 1.0)
202
+ fill = paint(style["fill"] || "black")
203
+ stroke = paint(style["stroke"] || "none")
204
+ commands = ["fill #{fill}", "stroke #{stroke}"]
205
+ fill_opacity = opacity * number(style["fill-opacity"], 1.0)
206
+ commands << "fill-opacity #{format_number(fill_opacity)}" if fill != "none" && fill_opacity < 1
207
+ commands.concat(stroke_commands(style, opacity)) unless stroke == "none"
208
+ commands << "fill-rule #{style['fill-rule'] == 'evenodd' ? 'evenodd' : 'nonzero'}"
209
+ commands
210
+ end
211
+
212
+ def stroke_commands(style, opacity)
213
+ commands = ["stroke-width #{format_number(number(style['stroke-width'], 1.0))}"]
214
+ stroke_opacity = opacity * number(style["stroke-opacity"], 1.0)
215
+ commands << "stroke-opacity #{format_number(stroke_opacity)}" if stroke_opacity < 1
216
+ cap = style["stroke-linecap"]
217
+ join = style["stroke-linejoin"]
218
+ commands << "stroke-linecap #{cap}" if %w[butt round square].include?(cap)
219
+ commands << "stroke-linejoin #{join}" if %w[miter round bevel].include?(join)
220
+ commands
221
+ end
222
+
223
+ def paint(value)
224
+ value = value.to_s.strip
225
+ value = @colors.fetch("accent", "black") if value.casecmp?("currentColor")
226
+ return "none" if value.casecmp?("none") || value.empty?
227
+ raise Error, "#{value.inspect} in the card template is not a colour" unless value.match?(COLOR)
228
+
229
+ "'#{value}'"
230
+ end
231
+
232
+ # A text slot, set and drawn as one path per line.
233
+ def slot(node, matrix, style)
234
+ name = node["data-field"].to_s
235
+ unless FIELDS.include?(name)
236
+ @ignored << "text #{name.empty? ? 'without data-field' : name}"
237
+ return
238
+ end
239
+
240
+ text = @fields[name].to_s.strip
241
+ return if text.empty?
242
+
243
+ setter = Typesetter.new(node["data-font"] || "sans")
244
+ text_box = text_box(node)
245
+ size, lines = setter.fit(text, text_box)
246
+ left = number(node["x"])
247
+ top = number(node["y"]) + (setter.cap_height * size)
248
+ paths = lines.each_with_index.map do |line, index|
249
+ data = setter.path(line.text, left, top + (index * size * text_box.line_height), size)
250
+ "path '#{data}'" unless data.empty?
251
+ end.compact
252
+ emit(matrix, style.merge("stroke" => "none"), *paths) unless paths.empty?
253
+ end
254
+
255
+ def text_box(node)
256
+ size = number(node["font-size"], 32)
257
+ height = node["height"] || node["data-height"]
258
+ Typesetter::Box.new(width: number(node["width"] || node["data-width"], WIDTH),
259
+ height: height && number(height), font_size: size,
260
+ min_font_size: number(node["data-min-font-size"], size),
261
+ max_lines: number(node["data-max-lines"], 1).to_i,
262
+ line_height: number(node["data-line-height"], 1.2))
263
+ end
264
+
265
+ # The logo, fitted in the slot's box and centred in it: an SVG drawn
266
+ # with the rest of the card, or an image laid over it.
267
+ def logo(node, matrix)
268
+ return if @logo.nil?
269
+
270
+ left, top, width, height = box(node)
271
+ if @logo.lstrip.start_with?("<")
272
+ svg = Template.new(@logo, colors: @colors, resolve: @resolve)
273
+ @out.concat(svg.fitted(multiply(matrix, [1.0, 0.0, 0.0, 1.0, left, top]), width, height))
274
+ @ignored.concat(svg.ignored)
275
+ else
276
+ image_logo(@logo, matrix, box(node))
277
+ end
278
+ end
279
+
280
+ def image_logo(file, matrix, box)
281
+ raise Error, "the logo #{file} has a quote in its path" if file.include?("'")
282
+
283
+ left, top, width, height = box
284
+ natural = FastImage.size(file) || [width, height]
285
+ scale = [width / natural[0].to_f, height / natural[1].to_f].min
286
+ drawn = [natural[0] * scale, natural[1] * scale]
287
+ corner = pair(left + ((width - drawn[0]) / 2), top + ((height - drawn[1]) / 2))
288
+ emit(matrix, {}, "image over #{corner} #{pair(*drawn)} '#{file}'")
289
+ end
290
+
291
+ # Scales this SVG's view box into a width and height, keeping its shape.
292
+ def fitted_matrix(width, height)
293
+ root = @document.root
294
+ view = root["viewBox"].to_s.scan(NUMBER).map(&:to_f)
295
+ view = [0.0, 0.0, length(root["width"], width), length(root["height"], height)] unless view.size == 4
296
+ scale = [width / view[2], height / view[3]].min
297
+ dx = ((width - (view[2] * scale)) / 2) - (view[0] * scale)
298
+ dy = ((height - (view[3] * scale)) / 2) - (view[1] * scale)
299
+ [scale, 0.0, 0.0, scale, dx, dy]
300
+ end
301
+
302
+ def length(value, fallback)
303
+ found = value.to_s[NUMBER]
304
+ found ? found.to_f : fallback.to_f
305
+ end
306
+
307
+ def number(value, fallback = 0)
308
+ found = value.to_s[/\A\s*#{NUMBER}/o]
309
+ found ? found.to_f : fallback.to_f
310
+ end
311
+
312
+ def pair(first, second)
313
+ "#{format_number(first)},#{format_number(second)}"
314
+ end
315
+
316
+ def format_number(value)
317
+ rounded = value.to_f.round(3)
318
+ rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
2
+ <!--
3
+ The theme's share card. lib/datalog/social_cards/template.rb says which
4
+ part of SVG a template may use. Each <text data-field> is a slot the build
5
+ fills, sets in the theme's fonts and draws as outlines; y is the top of
6
+ its box. {{background}}, {{ink}}, {{muted}} and {{accent}} are the
7
+ scheme's colours.
8
+ -->
9
+ <rect width="1200" height="630" fill="{{background}}"/>
10
+ <rect x="0" y="618" width="1200" height="12" fill="{{accent}}"/>
11
+ <rect data-field="logo" x="80" y="70" width="52" height="52"/>
12
+ <text data-field="site" x="148" y="86" width="972" font-size="28" data-min-font-size="22" data-font="sans" fill="{{muted}}"/>
13
+ <text data-field="kicker" x="80" y="182" width="1040" font-size="28" data-min-font-size="22" data-font="sans" fill="{{accent}}"/>
14
+ <text data-field="title" x="80" y="236" width="1040" height="262" font-size="66" data-min-font-size="40" data-max-lines="4" data-line-height="1.15" data-font="serif" fill="{{ink}}"/>
15
+ <text data-field="byline" x="80" y="528" width="1040" font-size="28" data-min-font-size="22" data-font="sans" fill="{{muted}}"/>
16
+ <text data-field="detail" x="80" y="570" width="1040" font-size="24" data-min-font-size="20" data-font="sans" fill="{{muted}}"/>
17
+ </svg>