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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +55 -3
- data/_data/i18n/es.yml +55 -3
- data/_data/i18n/pt.yml +55 -3
- data/_includes/analytics/dashboard.html +24 -9
- data/_includes/components/academic-dashboard.html +168 -133
- data/_includes/components/author-bio.html +7 -5
- data/_includes/components/author-list.html +6 -5
- data/_includes/components/citation-tools.html +2 -1
- data/_includes/components/content-provenance.html +19 -1
- data/_includes/components/correction-report.html +15 -8
- data/_includes/components/hero.html +22 -15
- data/_includes/components/package-index.html +121 -0
- data/_includes/components/package-install.html +27 -50
- data/_includes/components/post-list.html +14 -0
- data/_includes/components/responsive-image.html +15 -21
- data/_includes/footer.html +1 -1
- data/_includes/head.html +70 -11
- data/_includes/layouts/default/article.html +76 -46
- data/_includes/meta/dataset-json.html +171 -0
- data/_includes/meta/math-config.html +15 -1
- data/_includes/meta/package-json.html +69 -0
- data/_includes/meta/person-json.html +36 -13
- data/_includes/meta/publisher.html +15 -0
- data/_includes/meta/schema.html +108 -23
- data/_includes/meta/scholarly.html +10 -5
- data/_includes/post/related-posts.html +17 -43
- data/_includes/scripts.html +2 -2
- data/_includes/search/index-data.json +10 -1
- data/_includes/search/page.html +11 -6
- data/_layouts/archive.html +67 -0
- data/_layouts/dataset.html +2 -1
- data/_layouts/default.html +13 -11
- data/_layouts/docs.html +53 -0
- data/_layouts/home.html +30 -1
- data/_layouts/notebook.html +5 -1
- data/_layouts/package.html +38 -21
- data/_layouts/portfolio.html +1 -0
- data/_layouts/post.html +21 -62
- data/_layouts/project.html +2 -1
- data/_layouts/research.html +14 -2
- data/_plugins/analytics_dashboard.rb +4 -1
- data/_plugins/archive.rb +114 -0
- data/_plugins/authors.rb +50 -1
- data/_plugins/citation_exports.rb +12 -1
- data/_plugins/config_validator.rb +77 -2
- data/_plugins/correction_fallback.rb +110 -0
- data/_plugins/image_optimizer.rb +129 -22
- data/_plugins/math_preprocessor.rb +7 -48
- data/_plugins/notebook_converter.rb +44 -5
- data/_plugins/packages.rb +84 -0
- data/_plugins/page_dates.rb +37 -0
- data/_plugins/references.rb +16 -2
- data/_plugins/related_posts.rb +149 -0
- data/_plugins/search_sections.rb +110 -0
- data/_plugins/site_identity.rb +42 -0
- data/_plugins/social_cards.rb +17 -0
- data/_sass/_academic-dashboard.scss +18 -30
- data/_sass/_base.scss +2 -0
- data/_sass/_components.scss +21 -23
- data/_sass/_docs.scss +143 -0
- data/_sass/_features.scss +6 -0
- data/_sass/_layout.scss +65 -6
- data/_sass/_mathematical.scss +61 -19
- data/_sass/_notebooks.scss +1 -32
- data/_sass/_package-docs.scss +6 -9
- data/_sass/_post-components.scss +23 -23
- data/_sass/_print.scss +1 -1
- data/_sass/_search-page.scss +75 -0
- data/_sass/_search.scss +17 -26
- data/_sass/_syntax-highlighting.scss +23 -1
- data/_sass/_theme.scss +1 -0
- data/_sass/_utilities.scss +87 -40
- data/_sass/_variables.scss +31 -29
- data/assets/css/main.scss +2 -1
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
- data/assets/js/dist/comments.js +1 -1
- data/assets/js/dist/contact.js +1 -1
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +5 -1
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +2 -1
- data/assets/js/dist/moderation.js +1 -1
- data/assets/js/dist/reactions.js +1 -1
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +20 -16
- data/assets/js/dist/subscriptions.js +1 -1
- data/assets/js/loader.js +6 -2
- data/datalog-theme.gemspec +1 -1
- data/lib/datalog/audit/checks.rb +269 -0
- data/lib/datalog/audit/known_keys.rb +61 -0
- data/lib/datalog/audit/site_reader.rb +118 -0
- data/lib/datalog/audit/source_file.rb +85 -0
- data/lib/datalog/audit.rb +145 -0
- data/lib/datalog/citations/bibtex.rb +200 -0
- data/lib/datalog/citations/entry.rb +333 -0
- data/lib/datalog/citations/markup.rb +79 -0
- data/lib/datalog/cli.rb +164 -89
- data/lib/datalog/critical_css.rb +126 -21
- data/lib/datalog/latex_speech/words.json +88 -0
- data/lib/datalog/latex_speech.rb +355 -0
- data/lib/datalog/packages/command.rb +51 -0
- data/lib/datalog/packages/refresh.rb +187 -0
- data/lib/datalog/packages.rb +177 -0
- data/lib/datalog/plugin_system.rb +5 -0
- data/lib/datalog/plugins/citations.rb +325 -109
- data/lib/datalog/site_config.rb +26 -0
- data/lib/datalog/social_cards/font.rb +286 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
- data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
- data/lib/datalog/social_cards/geometry.rb +51 -0
- data/lib/datalog/social_cards/outline.rb +54 -0
- data/lib/datalog/social_cards/plain_text.rb +258 -0
- data/lib/datalog/social_cards/template.rb +322 -0
- data/lib/datalog/social_cards/template.svg +17 -0
- data/lib/datalog/social_cards/typesetter.rb +178 -0
- data/lib/datalog/social_cards.rb +369 -0
- data/lib/datalog/theme/updater.rb +307 -0
- data/lib/datalog/theme/version.rb +1 -1
- metadata +46 -9
- data/_includes/components/enhanced-code-block.html +0 -212
- data/_includes/components/performance-monitor.html +0 -170
- data/_includes/components/viz-table-fallback.html +0 -19
- data/_plugins/datalog_bibliography.rb +0 -21
- 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>
|