1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
|
# frozen_string_literal: true
require 'ostruct'
require_relative 'measurements'
module Asciidoctor
module PDF
class ThemeLoader
include ::Asciidoctor::PDF::Measurements
include ::Asciidoctor::Logging
DataDir = ::File.absolute_path %(#{__dir__}/../../../data)
ThemesDir = ::File.join DataDir, 'themes'
FontsDir = ::File.join DataDir, 'fonts'
BaseThemePath = ::File.join ThemesDir, 'base-theme.yml'
BundledThemeNames = (::Dir.children ThemesDir).map {|it| it.slice 0, it.length - 10 }
DeprecatedCategoryKeys = { 'blockquote' => 'quote', 'key' => 'kbd', 'literal' => 'codespan', 'outline_list' => 'list' }
DeprecatedKeys = { 'table_caption_side' => 'table_caption_end' }.tap {|accum| %w(base heading heading_h1 heading_h2 heading_h3 heading_h4 heading_h5 heading_h6 title_page abstract abstract_title admonition_label sidebar_title toc_title).each {|prefix| accum[%(#{prefix}_align)] = %(#{prefix}_text_align) } }
PaddingBottomHackKeys = %w(example_padding quote_padding sidebar_padding verse_padding)
VariableRx = /\$([a-z0-9_-]+)/
LoneVariableRx = /^\$([a-z0-9_-]+)$/
HexColorEntryRx = /^(?<k> *\p{Graph}+): +(?!null$)(?<q>["']?)(?<h>#)?(?<v>\h\h\h\h{0,3})\k<q> *(?:#.*)?$/
MultiplyDivideOpRx = %r((-?\d+(?:\.\d+)?) +([*/^]) +(-?\d+(?:\.\d+)?))
AddSubtractOpRx = /(-?\d+(?:\.\d+)?) +([+-]) +(-?\d+(?:\.\d+)?)/
PrecisionFuncRx = /^(round|floor|ceil)\(/
RelativeUnitsRx = /(?<=\d)(r?em)(?= )/
RoleAlignKeyRx = /(?:_text)?_align$/
module ColorValue; end
# A marker module for a normalized CMYK array
# Prevents normalizing CMYK value more than once
module CMYKColorValue
include ColorValue
def to_s
%([#{join ', '}])
end
end
class HexColorValue < String
include ColorValue
end
class TransparentColorValue < String
include ColorValue
end
def self.resolve_theme_file theme_name = nil, theme_dir = nil
# NOTE: if .yml extension is given, assume it's a path (don't append -theme.yml)
if theme_name&.end_with? '.yml'
# FIXME: restrict to jail!
if theme_dir
theme_path = ::File.absolute_path theme_name, (theme_dir = ::File.expand_path theme_dir)
else
theme_path = ::File.expand_path theme_name
theme_dir = ::File.dirname theme_path
end
else
theme_dir = theme_dir ? (::File.expand_path theme_dir) : ThemesDir
theme_path = ::File.absolute_path ::File.join theme_dir, %(#{theme_name || 'default'}-theme.yml)
end
[theme_path, theme_dir]
end
def self.resolve_theme_asset asset_path, theme_dir = nil
::File.absolute_path asset_path, (theme_dir || ThemesDir)
end
# NOTE: base theme is loaded "as is" (no post-processing)
def self.load_base_theme
::File.open BaseThemePath, mode: 'r:UTF-8' do |io|
(::OpenStruct.new ::YAML.safe_load io, filename: BaseThemePath).tap {|theme| theme.__dir__ = ThemesDir }
end
end
def self.load_theme theme_name = nil, theme_dir = nil
theme_path, theme_dir = resolve_theme_file theme_name, theme_dir
if theme_path == BaseThemePath
load_base_theme
else
theme_data = load_file theme_path, (::OpenStruct.new base_font_size: 12), theme_dir
unless (::File.dirname theme_path) == ThemesDir
theme_data.base_text_align ||= 'left'
theme_data.base_line_height ||= 1
theme_data.base_font_color ||= '000000'
theme_data.code_font_family ||= (theme_data.codespan_font_family || 'Courier')
theme_data.conum_font_family ||= (theme_data.codespan_font_family || 'Courier')
if (heading_font_family = theme_data.heading_font_family)
theme_data.abstract_title_font_family ||= heading_font_family
theme_data.sidebar_title_font_family ||= heading_font_family
end
end
theme_data.delete_field :__loaded__
theme_data.__dir__ = theme_dir
theme_data
end
end
def self.load_file filename, theme_data = nil, theme_dir = nil
data = ::File.read filename, mode: 'r:UTF-8', newline: :universal
data = data.each_line.map do |line|
line.sub(HexColorEntryRx) { %(#{(m = $~)[:k]}: #{m[:h] || (m[:k].end_with? 'color') ? "'#{m[:v]}'" : m[:v]}) }
end.join unless (::File.dirname filename) == ThemesDir
yaml_data = ::YAML.safe_load data, aliases: true, filename: filename
(loaded = (theme_data ||= ::OpenStruct.new).__loaded__ ||= ::Set.new).add filename
if ::Hash === yaml_data && (extends = yaml_data.delete 'extends')
(Array extends).each do |extend_path|
extend_path = extend_path.slice 0, extend_path.length - 11 if (force = extend_path.end_with? ' !important')
if extend_path == 'base'
theme_data = ::OpenStruct.new theme_data.to_h.merge load_base_theme.to_h if (loaded.add? 'base') || force
next
elsif BundledThemeNames.include? extend_path
extend_path, extend_theme_dir = resolve_theme_file extend_path, ThemesDir
elsif extend_path.start_with? './'
extend_path, extend_theme_dir = resolve_theme_file extend_path, (::File.dirname filename)
else
extend_path, extend_theme_dir = resolve_theme_file extend_path, theme_dir
end
theme_data = load_file extend_path, theme_data, extend_theme_dir if (loaded.add? extend_path) || force
end
end
new.load yaml_data, theme_data
end
def load hash, theme_data = nil
::Hash === hash ? hash.reduce(theme_data || ::OpenStruct.new) {|data, (key, val)| process_entry key, val, data, true } : (theme_data || ::OpenStruct.new)
end
private
def process_entry key, val, data, normalize_key = false
key = key.tr '-', '_' if normalize_key && (key.include? '-')
if key == 'font'
val.each do |subkey, subval|
process_entry %(#{key}_#{subkey}), subval, data if subkey == 'catalog' || subkey == 'fallbacks'
end if ::Hash === val
elsif key == 'font_catalog'
data[key] = ::Hash === val ? (val.reduce (val.delete 'merge') ? data[key] || {} : {} do |accum, (name, styles)| # rubocop:disable Style/EachWithObject
styles = { '*' => styles } if ::String === styles
accum[name] = styles.reduce({}) do |subaccum, (style, path)| # rubocop:disable Style/EachWithObject
if (path.start_with? 'GEM_FONTS_DIR') && (sep = path[13])
path = %(#{FontsDir}#{sep}#{path.slice 14, path.length})
end
expanded_path = expand_vars path, data
case style
when '*'
%w(normal bold italic bold_italic).map {|it| subaccum[it] = expanded_path }
when 'regular'
subaccum['normal'] = expanded_path
else
subaccum[style] = expanded_path
end
subaccum
end if ::Hash === styles
accum
end) : nil
elsif key == 'font_fallbacks'
data[key] = ::Array === val ? val.map {|name| expand_vars name.to_s, data } : []
elsif key.start_with? 'admonition_icon_'
data[key] = {}.tap do |accum|
val.each do |key2, val2|
key2 = key2.tr '-', '_' if key2.include? '-'
accum[key2.to_sym] = (key2.end_with? '_color') ? (to_color evaluate val2, data, math: false) : (evaluate val2, data)
end
end if val
elsif ::Hash === val
if (rekey = DeprecatedCategoryKeys[key])
logger.warn %(the #{key.tr '_', '-'} theme category is deprecated; use the #{rekey.tr '_', '-'} category instead)
key = rekey
end
val.each do |subkey, subval|
process_entry %(#{key}_#{key == 'role' || !(subkey.include? '-') ? subkey : (subkey.tr '-', '_')}), subval, data
end
elsif (rekey = DeprecatedKeys[key]) ||
((key.start_with? 'role_') && (key.end_with? '_align') && (rekey = key.sub RoleAlignKeyRx, '_text_align'))
data[rekey] = evaluate val, data, math: false
elsif PaddingBottomHackKeys.include? key
val = evaluate val, data
# normalize padding hacks for themes designed before the converter had smart margins
val[2] = val[0] if ::Array === val && val[0].to_f >= 0 && val[2].to_f <= 0
data[key] = val
elsif key.end_with? '_color'
# assume table_grid_color is a single color unless the value is a 2-element array for backwards compatibility
if key == 'table_border_color' ? ::Array === val : (key == 'table_grid_color' && ::Array === val && val.size == 2)
data[key] = val.map {|it| to_color evaluate it, data, math: false }
else
data[key] = to_color evaluate val, data, math: false
end
elsif (key.end_with? '_content') || (key == 'kbd_separator' && (rekey = key = 'kbd_separator_content'))
logger.warn %(the kbd-separator theme key is deprecated; use the kbd-separator-content key instead) if rekey
data[key] = (expand_vars val.to_s, data).to_s
else
data[key] = evaluate val, data
end
data
end
def evaluate expr, vars, math: true
case expr
when ::String
math ? (evaluate_math expand_vars expr, vars) : (expand_vars expr, vars)
when ::Array
expr.map {|e| evaluate e, vars, math: math }
else
expr
end
end
# NOTE: we assume expr is a String
def expand_vars expr, vars
return expr unless (idx = expr.index '$')
if idx == 0
return resolve_var vars, expr, $1 if expr =~ LoneVariableRx
elsif idx == 1 && expr.chr == '-' && (negated_expr = expr.slice 1, expr.length) =~ LoneVariableRx
return Numeric === (val = resolve_var vars, negated_expr, $1) ? -val : '-' + val
end
expr.gsub(VariableRx) { resolve_var vars, $&, $1 }
end
def resolve_var vars, ref, var
var = var.tr '-', '_' if var.include? '-'
if (vars.respond_to? var) ||
DeprecatedCategoryKeys.any? {|old, new| (var.start_with? old + '_') && (vars.respond_to? (replace = new + (var.slice old.length, var.length))) && (var = replace) } ||
((replace = DeprecatedKeys[var]) && (vars.respond_to? replace) && (var = replace))
vars[var]
else
logger.warn %(unknown variable reference in PDF theme: #{ref})
ref
end
end
def evaluate_math expr
return expr if !(::String === expr) || ColorValue === expr
# resolve measurement values (e.g., 0.5in => 36)
# NOTE: leave % as a string; handled by converter for now
original, expr = expr, (resolve_measurement_values expr)
if (expr.include? 'em ') && (segments = expr.split RelativeUnitsRx, 2).length == 3
units = segments.delete_at 1
expr = segments.join
end
while (expr.count '*/^') > 0
result = expr.gsub(MultiplyDivideOpRx) { $1.to_f.send ($2 == '^' ? '**' : $2).to_sym, $3.to_f }
unchanged = (result == expr)
expr = result
break if unchanged
end
while (expr.count '+-') > 0
result = expr.gsub(AddSubtractOpRx) { $1.to_f.send $2.to_sym, $3.to_f }
unchanged = (result == expr)
expr = result
break if unchanged
end
if (expr.end_with? ')') && expr =~ PrecisionFuncRx
offset = (op = $1).length + 1
expr = expr[offset...-1].to_f.send op.to_sym
end
if expr == original
original
else
expr = (int_val = expr.to_i) == (flt_val = expr.to_f) ? int_val : flt_val
units ? %(#{expr}#{units}) : expr
end
end
def to_color value
case value
when ColorValue
# already converted
return value
when ::Array
case value.length
# CMYK value
when 4
value = value.map do |e|
if ::Numeric === e
e *= 100.0 unless e > 1
else
e = (e.to_s.chomp '%').to_f
end
e == (int_e = e.to_i) ? int_e : e
end
case value
when [0, 0, 0, 0]
return HexColorValue.new 'FFFFFF'
when [100, 100, 100, 100]
return HexColorValue.new '000000'
else
return value.extend CMYKColorValue
end
# RGB value
when 3
return HexColorValue.new value.map {|e| sprintf '%02X', e }.join
# Nonsense array value; flatten to string
else
value = value.join
end
when ::String
return TransparentColorValue.new value if value == 'transparent'
return HexColorValue.new value.upcase if value.length == 6
when ::NilClass
return
else
# Unknown type (usually Integer); coerce to String
if (value = value.to_s).length == 6
return HexColorValue.new value.upcase
end
end
case value.length
when 6
resolved_value = value
when 3
# expand hex shorthand (e.g., f00 -> ff0000)
resolved_value = value.each_char.map {|c| c * 2 }.join
else
# truncate or pad with leading zeros (e.g., ff -> 0000ff)
resolved_value = (value.slice 0, 6).rjust 6, '0'
end
HexColorValue.new resolved_value.upcase
end
end
end
end
|