asciidoctor-latexmath 1.0.0.pre.dev.1 → 2.0.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +119 -36
  3. data/lib/asciidoctor/latexmath/attribute_resolver.rb +485 -0
  4. data/lib/asciidoctor/latexmath/cache/cache_entry.rb +31 -0
  5. data/lib/asciidoctor/latexmath/cache/cache_key.rb +33 -0
  6. data/lib/asciidoctor/latexmath/cache/disk_cache.rb +151 -0
  7. data/lib/asciidoctor/latexmath/command_runner.rb +116 -0
  8. data/lib/asciidoctor/latexmath/converters/html5.rb +72 -0
  9. data/lib/asciidoctor/latexmath/errors.rb +134 -0
  10. data/lib/asciidoctor/latexmath/html_builder.rb +103 -0
  11. data/lib/asciidoctor/latexmath/math_expression.rb +22 -0
  12. data/lib/asciidoctor/latexmath/path_utils.rb +46 -0
  13. data/lib/asciidoctor/latexmath/processors/block_processor.rb +74 -0
  14. data/lib/asciidoctor/latexmath/processors/inline_macro_processor.rb +70 -0
  15. data/lib/asciidoctor/latexmath/processors/statistics_postprocessor.rb +18 -0
  16. data/lib/asciidoctor/latexmath/render_request.rb +32 -0
  17. data/lib/asciidoctor/latexmath/renderer_service.rb +748 -0
  18. data/lib/asciidoctor/latexmath/rendering/pdf_to_png_renderer.rb +97 -0
  19. data/lib/asciidoctor/latexmath/rendering/pdf_to_svg_renderer.rb +84 -0
  20. data/lib/asciidoctor/latexmath/rendering/pdflatex_renderer.rb +166 -0
  21. data/lib/asciidoctor/latexmath/rendering/pipeline.rb +58 -0
  22. data/lib/asciidoctor/latexmath/rendering/renderer.rb +33 -0
  23. data/lib/asciidoctor/latexmath/rendering/tool_detector.rb +320 -0
  24. data/lib/asciidoctor/latexmath/rendering/toolchain_record.rb +21 -0
  25. data/lib/asciidoctor/latexmath/statistics/collector.rb +47 -0
  26. data/lib/asciidoctor/latexmath/support/conflict_registry.rb +75 -0
  27. data/lib/{asciidoctor-latexmath → asciidoctor/latexmath}/version.rb +1 -1
  28. data/lib/asciidoctor-latexmath.rb +93 -3
  29. metadata +34 -12
  30. data/lib/asciidoctor-latexmath/renderer.rb +0 -515
  31. data/lib/asciidoctor-latexmath/treeprocessor.rb +0 -369
@@ -0,0 +1,485 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Shuai Zhang
4
+ #
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
6
+
7
+ require "digest"
8
+ require "pathname"
9
+
10
+ require_relative "errors"
11
+ require_relative "render_request"
12
+ require_relative "path_utils"
13
+
14
+ module Asciidoctor
15
+ module Latexmath
16
+ class AttributeResolver
17
+ MIN_PPI = 72
18
+ MAX_PPI = 600
19
+ DEFAULT_PPI = 300
20
+ DEFAULT_TIMEOUT = 120
21
+ SUPPORTED_ENGINES = %i[pdflatex xelatex lualatex tectonic].freeze
22
+ ENGINE_DEFAULTS = {
23
+ pdflatex: "pdflatex",
24
+ xelatex: "xelatex",
25
+ lualatex: "lualatex",
26
+ tectonic: "tectonic"
27
+ }.freeze
28
+
29
+ ResolvedAttributes = Struct.new(
30
+ :render_request,
31
+ :on_error_policy,
32
+ :target_basename,
33
+ :format,
34
+ :nocache,
35
+ :keep_artifacts,
36
+ :raw_attributes,
37
+ keyword_init: true
38
+ )
39
+
40
+ def initialize(document, logger: Asciidoctor::LoggerManager.logger)
41
+ @document = document
42
+ @logger = logger
43
+ end
44
+
45
+ def resolve(attributes:, options:, expression:)
46
+ normalized = normalize_keys(attributes)
47
+ normalized = apply_aliases(normalized)
48
+ options = Array(options)
49
+
50
+ format = infer_format(normalized)
51
+ engine = infer_engine(normalized)
52
+ ppi = infer_ppi(normalized, format)
53
+ timeout = infer_timeout(normalized)
54
+ nocache = infer_nocache(normalized, options)
55
+ keep_artifacts = infer_keep_artifacts(normalized, options)
56
+ cachedir = infer_cachedir(normalized) unless nocache
57
+ artifacts_dir = infer_artifacts_dir(normalized, keep_artifacts, cachedir)
58
+ preamble = infer_preamble(normalized)
59
+ fontsize = infer_fontsize(normalized)
60
+ on_error_policy = infer_on_error(normalized)
61
+ tool_overrides = infer_tool_overrides(normalized)
62
+
63
+ normalized_content = normalize_text(expression.content.to_s)
64
+ normalized_preamble = normalize_text(preamble)
65
+ normalized_fontsize = normalize_text(fontsize)
66
+
67
+ target_basename = determine_target_basename(normalized)
68
+
69
+ render_request = RenderRequest.new(
70
+ expression: expression,
71
+ format: format,
72
+ engine: engine,
73
+ preamble: preamble,
74
+ fontsize: fontsize,
75
+ ppi: ppi,
76
+ timeout: timeout,
77
+ keep_artifacts: keep_artifacts,
78
+ nocache: nocache,
79
+ cachedir: cachedir,
80
+ artifacts_dir: artifacts_dir,
81
+ tool_overrides: tool_overrides,
82
+ content_hash: Digest::SHA256.hexdigest(normalized_content),
83
+ preamble_hash: Digest::SHA256.hexdigest(normalized_preamble),
84
+ fontsize_hash: Digest::SHA256.hexdigest(normalized_fontsize)
85
+ )
86
+
87
+ ResolvedAttributes.new(
88
+ render_request: render_request,
89
+ on_error_policy: on_error_policy,
90
+ target_basename: target_basename,
91
+ format: format,
92
+ nocache: nocache,
93
+ keep_artifacts: keep_artifacts,
94
+ raw_attributes: normalized
95
+ )
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :document, :logger
101
+
102
+ def normalize_keys(attributes)
103
+ attributes.each_with_object({}) do |(key, value), memo|
104
+ memo[key.to_s] = value
105
+ end
106
+ end
107
+
108
+ def apply_aliases(attrs)
109
+ if attrs.key?("cache-dir")
110
+ log_cache_dir_alias_once(:element)
111
+ attrs["cachedir"] = attrs.delete("cache-dir")
112
+ end
113
+ attrs
114
+ end
115
+
116
+ def determine_target_basename(attrs)
117
+ explicit = attrs["target"]
118
+ return explicit unless explicit.to_s.empty?
119
+
120
+ positional = attrs["2"]
121
+ style = attrs["style"]
122
+ return positional if positional && !positional.to_s.empty? && positional != style
123
+
124
+ positional = attrs["1"]
125
+ return positional if positional && !positional.to_s.empty? && positional != style
126
+
127
+ nil
128
+ end
129
+
130
+ def infer_format(attrs)
131
+ value = attrs["format"] || document.attr("latexmath-format") || "svg"
132
+ normalized = value.to_s.downcase.to_sym
133
+ return normalized if %i[svg pdf png].include?(normalized)
134
+
135
+ raise UnsupportedFormatError, "Unsupported format '#{value}'"
136
+ end
137
+
138
+ def infer_engine(attrs)
139
+ engine_name = determine_engine_name(attrs)
140
+ command = resolve_engine_command(engine_name, attrs)
141
+ command.to_s.strip
142
+ end
143
+
144
+ def infer_preamble(attrs)
145
+ override = attrs["preamble"]
146
+ return override.to_s if override
147
+
148
+ (document.attr("latexmath-preamble") || "").to_s
149
+ end
150
+
151
+ def infer_fontsize(attrs)
152
+ raw_value, subject = fetch_attribute_value(attrs, "fontsize", "latexmath-fontsize")
153
+ effective_value = value_or_default(raw_value, "12pt")
154
+ value = effective_value.to_s.strip
155
+ value = "12pt" if value.empty?
156
+
157
+ unless valid_fontsize?(value)
158
+ raise_unsupported_attribute(subject, raw_value || value,
159
+ supported: "values ending with 'pt' (e.g., 10pt, 12pt)",
160
+ hint: "set #{subject} to a positive value ending with 'pt'")
161
+ end
162
+
163
+ value
164
+ end
165
+
166
+ def infer_ppi(attrs, format)
167
+ return nil unless format == :png
168
+
169
+ raw_value, subject = fetch_attribute_value(attrs, "ppi", "latexmath-ppi")
170
+ effective_value = value_or_default(raw_value, DEFAULT_PPI)
171
+
172
+ begin
173
+ integer = Integer(effective_value)
174
+ rescue ArgumentError, TypeError
175
+ raise_unsupported_attribute(subject, raw_value, supported: "integer between #{MIN_PPI} and #{MAX_PPI}",
176
+ hint: "set #{subject} to an integer between #{MIN_PPI} and #{MAX_PPI}")
177
+ end
178
+
179
+ unless integer.between?(MIN_PPI, MAX_PPI)
180
+ raise_unsupported_attribute(subject, raw_value || integer,
181
+ supported: "integer between #{MIN_PPI} and #{MAX_PPI}",
182
+ hint: "set #{subject} between #{MIN_PPI} and #{MAX_PPI}")
183
+ end
184
+ integer
185
+ end
186
+
187
+ def infer_timeout(attrs)
188
+ raw_value, subject = fetch_attribute_value(attrs, "timeout", "latexmath-timeout")
189
+ effective_value = value_or_default(raw_value, DEFAULT_TIMEOUT)
190
+
191
+ begin
192
+ integer = Integer(effective_value)
193
+ rescue ArgumentError, TypeError
194
+ raise_unsupported_attribute(subject, raw_value,
195
+ supported: "positive integer seconds",
196
+ hint: "set #{subject} to a positive integer")
197
+ end
198
+
199
+ unless integer.positive?
200
+ raise_unsupported_attribute(subject, raw_value || integer,
201
+ supported: "positive integer seconds",
202
+ hint: "set #{subject} to a positive integer")
203
+ end
204
+
205
+ integer
206
+ end
207
+
208
+ def infer_cachedir(attrs)
209
+ explicit = attrs["cachedir"]
210
+ doc_level = canonical_cachedir_attr
211
+
212
+ return expand_path(explicit) if explicit
213
+ return expand_path(doc_level) if doc_level
214
+
215
+ File.join(resolve_outdir, ".asciidoctor", "latexmath")
216
+ end
217
+
218
+ def infer_artifacts_dir(attrs, keep_artifacts, cachedir)
219
+ return nil unless keep_artifacts
220
+
221
+ explicit = attrs["artifacts-dir"] || attrs["artifactsdir"]
222
+ doc_level = document&.attr("latexmath-artifacts-dir") || document&.attr("latexmath-artifactsdir")
223
+ chosen = explicit || doc_level
224
+
225
+ if chosen
226
+ expand_path(chosen)
227
+ elsif cachedir
228
+ File.join(cachedir, "artifacts")
229
+ end
230
+ end
231
+
232
+ def infer_tool_overrides(attrs)
233
+ svg_tool = fetch_string(attrs, "latexmath-svg-tool") || document&.attr("latexmath-svg-tool")
234
+ svg_path = fetch_string(attrs, "latexmath-pdf2svg") || document&.attr("latexmath-pdf2svg")
235
+ png_tool = fetch_string(attrs, "latexmath-png-tool") || fetch_string(attrs, "png-tool") || document&.attr("latexmath-png-tool") || document&.attr("png-tool")
236
+ png_path = fetch_string(attrs, "latexmath-pdftoppm") || document&.attr("latexmath-pdftoppm")
237
+ engine_tool = fetch_string(attrs, "pdflatex") || document&.attr("latexmath-pdflatex")
238
+
239
+ {
240
+ svg: normalize_override(svg_tool),
241
+ svg_path: normalize_override(svg_path),
242
+ png: normalize_override(png_tool),
243
+ png_path: normalize_override(png_path),
244
+ engine: normalize_override(engine_tool)
245
+ }
246
+ end
247
+
248
+ def determine_engine_name(attrs)
249
+ explicit = fetch_string(attrs, "engine")
250
+ return normalize_engine_name(explicit) if explicit
251
+
252
+ SUPPORTED_ENGINES.each do |engine|
253
+ key = engine.to_s
254
+ return engine if attrs.key?(key) || attrs.key?(engine)
255
+ end
256
+
257
+ document_engine = document&.attr("latexmath-engine")
258
+ return normalize_engine_name(document_engine) if document_engine
259
+
260
+ :pdflatex
261
+ end
262
+
263
+ def resolve_engine_command(engine_name, attrs)
264
+ element_override = fetch_string(attrs, engine_name.to_s)
265
+ return element_override if element_override
266
+
267
+ doc_override = document_engine_override(engine_name)
268
+ return doc_override if doc_override
269
+
270
+ ENGINE_DEFAULTS.fetch(engine_name) { ENGINE_DEFAULTS[:pdflatex] }
271
+ end
272
+
273
+ def document_engine_override(engine_name)
274
+ return nil unless document
275
+
276
+ candidates = []
277
+ candidates << document.attr("latexmath-#{engine_name}")
278
+ candidates << document.attr(engine_name.to_s)
279
+ if engine_name != :pdflatex
280
+ candidates << document.attr("latexmath-pdflatex")
281
+ candidates << document.attr("pdflatex")
282
+ end
283
+
284
+ candidates.compact.each do |candidate|
285
+ normalized = candidate.to_s.strip
286
+ return normalized unless normalized.empty?
287
+ end
288
+
289
+ nil
290
+ end
291
+
292
+ def normalize_engine_name(value)
293
+ return nil if value.nil?
294
+
295
+ normalized = value.to_s.strip.downcase
296
+ raise InvalidAttributeError, "engine cannot be blank" if normalized.empty?
297
+
298
+ candidate = normalized.gsub(/[^a-z0-9]+/, "_").to_sym
299
+ return candidate if SUPPORTED_ENGINES.include?(candidate)
300
+
301
+ raise InvalidAttributeError, "Unknown engine '#{value}'"
302
+ end
303
+
304
+ def infer_nocache(attrs, options)
305
+ if attrs.key?("cache")
306
+ parsed = parse_boolean(attrs["cache"])
307
+ return !parsed unless parsed.nil?
308
+ end
309
+
310
+ if attrs.key?("nocache")
311
+ parsed = parse_boolean(attrs["nocache"])
312
+ return parsed unless parsed.nil?
313
+ end
314
+
315
+ return true if options.include?("nocache")
316
+
317
+ doc_value = document && parse_boolean(document.attr("latexmath-cache"))
318
+ return !doc_value unless doc_value.nil?
319
+
320
+ false
321
+ end
322
+
323
+ def infer_keep_artifacts(attrs, options)
324
+ if attrs.key?("keep-artifacts")
325
+ parsed = parse_boolean(attrs["keep-artifacts"])
326
+ return parsed unless parsed.nil?
327
+ end
328
+
329
+ return true if options.include?("keep-artifacts")
330
+
331
+ doc_value = document && parse_boolean(document.attr("latexmath-keep-artifacts"))
332
+ doc_value || false
333
+ end
334
+
335
+ def infer_on_error(attrs)
336
+ raw_value, subject = fetch_attribute_value(attrs, "on-error", "latexmath-on-error")
337
+ effective_value = value_or_default(raw_value, :log)
338
+ normalized = effective_value.to_s.strip
339
+ normalized = "log" if normalized.empty?
340
+
341
+ valid = %w[abort log]
342
+ if valid.include?(normalized.downcase)
343
+ ErrorHandling.policy(normalized.downcase.to_sym)
344
+ else
345
+ raise_unsupported_attribute(subject, raw_value,
346
+ supported: valid,
347
+ hint: "set #{subject} to one of [abort, log]")
348
+ end
349
+ end
350
+
351
+ def expand_path(path)
352
+ PathUtils.expand_path(path, resolve_outdir)
353
+ end
354
+
355
+ def resolve_outdir
356
+ base_dir = document&.attr("outdir") || document&.options&.[](:to_dir) || document&.base_dir || document&.attr("docdir") || Dir.pwd
357
+ PathUtils.expand_path(base_dir, Dir.pwd)
358
+ end
359
+
360
+ def canonical_cachedir_attr
361
+ return nil unless document
362
+
363
+ primary = document.attr("latexmath-cachedir")
364
+ return primary if primary
365
+
366
+ deprecated = document.attr("latexmath-cache-dir")
367
+ if deprecated
368
+ log_cache_dir_alias_once(:document)
369
+ deprecated
370
+ end
371
+ end
372
+
373
+ def log_cache_dir_alias_once(_scope)
374
+ return unless document
375
+
376
+ flag_name = "latexmath-deprecated-cache-dir-logged"
377
+ return if parse_boolean(document.attr(flag_name))
378
+
379
+ logger&.info { "latexmath: cache-dir is deprecated, use cachedir instead" }
380
+ set_internal_document_attr(flag_name, true)
381
+ end
382
+
383
+ def fetch_string(attrs, key)
384
+ value = attrs[key] || attrs[key.to_s]
385
+ value = value.to_s if value
386
+ (value && !value.strip.empty?) ? value.strip : nil
387
+ end
388
+
389
+ def normalize_override(value)
390
+ return nil if value.nil?
391
+
392
+ stripped = value.to_s.strip
393
+ stripped.empty? ? nil : stripped
394
+ end
395
+
396
+ def normalize_text(text)
397
+ text.to_s.sub(/^\uFEFF/, "")
398
+ end
399
+
400
+ def parse_boolean(value)
401
+ return value if value == true || value == false
402
+ return nil if value.nil?
403
+
404
+ string = value.to_s.strip
405
+ normalized = string.downcase
406
+ return true if string.empty?
407
+ return true if %w[true yes on 1].include?(normalized)
408
+ return false if %w[false no off 0].include?(normalized)
409
+
410
+ nil
411
+ end
412
+
413
+ def fetch_attribute_value(attrs, element_key, document_key)
414
+ if attrs.key?(element_key)
415
+ return [attrs[element_key], element_key]
416
+ end
417
+
418
+ if document && (document_value = document.attr(document_key))
419
+ return [document_value, document_key]
420
+ end
421
+
422
+ [nil, element_key]
423
+ end
424
+
425
+ def value_or_default(raw_value, default_value)
426
+ return default_value if raw_value.nil?
427
+
428
+ if raw_value.respond_to?(:strip)
429
+ stripped = raw_value.strip
430
+ return default_value if stripped.empty?
431
+ stripped
432
+ else
433
+ raw_value
434
+ end
435
+ end
436
+
437
+ def raise_unsupported_attribute(subject, raw_value, supported:, hint:)
438
+ raise UnsupportedValueError.new(
439
+ category: :attribute,
440
+ subject: subject,
441
+ value: normalize_error_value(raw_value),
442
+ supported: supported,
443
+ hint: hint
444
+ )
445
+ end
446
+
447
+ def normalize_error_value(raw_value)
448
+ return raw_value if raw_value.nil?
449
+
450
+ raw_value.respond_to?(:strip) ? raw_value.strip : raw_value
451
+ end
452
+
453
+ def valid_fontsize?(value)
454
+ /
455
+ \A
456
+ (?:
457
+ \d+(?:\.\d+)?
458
+ )
459
+ pt
460
+ \z
461
+ /x.match?(value)
462
+ end
463
+
464
+ def set_internal_document_attr(name, value)
465
+ normalized = normalize_attribute_value(value)
466
+ if document.respond_to?(:set_attribute)
467
+ document.set_attribute(name, normalized)
468
+ elsif document.respond_to?(:set_attr)
469
+ document.set_attr(name, normalized)
470
+ elsif document.respond_to?(:attributes)
471
+ document.attributes[name] = normalized
472
+ end
473
+ end
474
+
475
+ def normalize_attribute_value(value)
476
+ case value
477
+ when true then "true"
478
+ when false then "false"
479
+ else
480
+ value
481
+ end
482
+ end
483
+ end
484
+ end
485
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Shuai Zhang
4
+ #
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
6
+
7
+ module Asciidoctor
8
+ module Latexmath
9
+ module Cache
10
+ class CacheEntry
11
+ attr_reader :final_path, :format, :content_hash, :preamble_hash, :fontsize, :engine,
12
+ :ppi, :entry_type, :created_at, :checksum, :size_bytes, :tool_presence
13
+
14
+ def initialize(final_path:, format:, content_hash:, preamble_hash:, fontsize:, engine:, ppi:, entry_type:, created_at:, checksum:, size_bytes:, tool_presence: {})
15
+ @final_path = final_path
16
+ @format = format
17
+ @content_hash = content_hash
18
+ @preamble_hash = preamble_hash
19
+ @fontsize = fontsize
20
+ @engine = engine
21
+ @ppi = ppi
22
+ @entry_type = entry_type
23
+ @created_at = created_at
24
+ @checksum = checksum
25
+ @size_bytes = size_bytes
26
+ @tool_presence = (tool_presence || {}).transform_keys(&:to_s)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Shuai Zhang
4
+ #
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
6
+
7
+ require "digest"
8
+
9
+ module Asciidoctor
10
+ module Latexmath
11
+ module Cache
12
+ class CacheKey
13
+ FIELDS_ORDER = %i[ext_version content_hash format preamble_hash fontsize_hash ppi entry_type].freeze
14
+
15
+ attr_reader(*FIELDS_ORDER)
16
+
17
+ def initialize(ext_version:, content_hash:, format:, preamble_hash:, fontsize_hash:, ppi:, entry_type:)
18
+ @ext_version = ext_version
19
+ @content_hash = content_hash
20
+ @format = format
21
+ @preamble_hash = preamble_hash
22
+ @fontsize_hash = fontsize_hash || "-"
23
+ @ppi = ppi || "-"
24
+ @entry_type = entry_type
25
+ end
26
+
27
+ def digest
28
+ @digest ||= Digest::SHA256.hexdigest(FIELDS_ORDER.map { |field| public_send(field).to_s }.join("\n"))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Shuai Zhang
4
+ #
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
6
+
7
+ require "fileutils"
8
+ require "json"
9
+ require "digest"
10
+ require "time"
11
+
12
+ module Asciidoctor
13
+ module Latexmath
14
+ module Cache
15
+ class DiskCache
16
+ METADATA_FILENAME = "metadata.json"
17
+ ARTIFACT_FILENAME = "artifact"
18
+ METADATA_VERSION = 1
19
+
20
+ def initialize(root)
21
+ @root = root
22
+ FileUtils.mkdir_p(@root)
23
+ end
24
+
25
+ def fetch(key_digest)
26
+ entry_dir = entry_dir(key_digest)
27
+ metadata_path = File.join(entry_dir, METADATA_FILENAME)
28
+ artifact_path = File.join(entry_dir, ARTIFACT_FILENAME)
29
+ return nil unless File.file?(metadata_path) && File.file?(artifact_path)
30
+
31
+ metadata = JSON.parse(File.read(metadata_path))
32
+ return nil unless valid_checksum?(artifact_path, metadata["checksum"])
33
+
34
+ CacheEntry.new(
35
+ final_path: artifact_path,
36
+ format: to_symbol(metadata["format"]),
37
+ content_hash: metadata["content_hash"],
38
+ preamble_hash: metadata["preamble_hash"],
39
+ fontsize: metadata["fontsize"],
40
+ engine: metadata["engine"],
41
+ ppi: metadata["ppi"],
42
+ entry_type: to_symbol(metadata["entry_type"]),
43
+ created_at: Time.parse(metadata["created_at"]),
44
+ checksum: metadata["checksum"],
45
+ size_bytes: metadata["size_bytes"],
46
+ tool_presence: metadata["tool_presence"] || {}
47
+ )
48
+ rescue JSON::ParserError, Errno::ENOENT
49
+ nil
50
+ end
51
+
52
+ def store(key_digest, cache_entry, source_path)
53
+ entry_dir = entry_dir(key_digest)
54
+ FileUtils.mkdir_p(entry_dir)
55
+ artifact_path = File.join(entry_dir, ARTIFACT_FILENAME)
56
+ metadata_path = File.join(entry_dir, METADATA_FILENAME)
57
+
58
+ checksum = Digest::SHA256.file(source_path).hexdigest
59
+ size_bytes = File.size(source_path)
60
+
61
+ temp_artifact = prepare_temp_path(artifact_path)
62
+ temp_metadata = prepare_temp_path(metadata_path)
63
+
64
+ begin
65
+ FileUtils.cp(source_path, temp_artifact)
66
+
67
+ metadata = {
68
+ "version" => METADATA_VERSION,
69
+ "key" => key_digest,
70
+ "format" => cache_entry.format.to_s,
71
+ "content_hash" => cache_entry.content_hash,
72
+ "preamble_hash" => cache_entry.preamble_hash,
73
+ "fontsize" => cache_entry.fontsize,
74
+ "engine" => cache_entry.engine,
75
+ "ppi" => cache_entry.ppi,
76
+ "entry_type" => cache_entry.entry_type.to_s,
77
+ "created_at" => cache_entry.created_at.utc.iso8601,
78
+ "checksum" => "sha256:#{checksum}",
79
+ "size_bytes" => size_bytes,
80
+ "tool_presence" => cache_entry.tool_presence
81
+ }
82
+
83
+ File.write(temp_metadata, JSON.pretty_generate(metadata))
84
+
85
+ FileUtils.mv(temp_artifact, artifact_path)
86
+ FileUtils.mv(temp_metadata, metadata_path)
87
+ ensure
88
+ FileUtils.rm_f(temp_artifact) if File.exist?(temp_artifact)
89
+ FileUtils.rm_f(temp_metadata) if File.exist?(temp_metadata)
90
+ end
91
+ end
92
+
93
+ def with_lock(key_digest)
94
+ lock_path = File.join(root, "#{key_digest}.lock")
95
+ FileUtils.mkdir_p(File.dirname(lock_path))
96
+
97
+ attempts = 0
98
+ base_sleep = 0.05
99
+
100
+ loop do
101
+ attempts += 1
102
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
103
+ if file.flock(File::LOCK_EX | File::LOCK_NB)
104
+ begin
105
+ return yield
106
+ ensure
107
+ file.flock(File::LOCK_UN)
108
+ end
109
+ end
110
+ end
111
+
112
+ raise IOError, "could not obtain cache lock for #{key_digest}" if attempts >= 5
113
+
114
+ sleep(base_sleep * (2**(attempts - 1)))
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ attr_reader :root
121
+
122
+ def entry_dir(key_digest)
123
+ File.join(root, key_digest)
124
+ end
125
+
126
+ def valid_checksum?(artifact_path, checksum_field)
127
+ return false unless checksum_field
128
+
129
+ algorithm, value = checksum_field.split(":", 2)
130
+ return false unless algorithm == "sha256" && value
131
+
132
+ Digest::SHA256.file(artifact_path).hexdigest == value
133
+ rescue Errno::ENOENT
134
+ false
135
+ end
136
+
137
+ def to_symbol(value)
138
+ return nil if value.nil?
139
+
140
+ value.to_s.strip.empty? ? nil : value.to_s.downcase.to_sym
141
+ end
142
+
143
+ def prepare_temp_path(path)
144
+ dir = File.dirname(path)
145
+ basename = File.basename(path)
146
+ File.join(dir, ".#{basename}.tmp-#{Process.pid}-#{Thread.current.object_id}")
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end