uniword 1.7.0 → 1.7.2
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 +6 -0
- data/lib/uniword/accessibility/rules/image_alt_text_rule.rb +36 -13
- data/lib/uniword/builder/image_builder.rb +2 -0
- data/lib/uniword/builder/paragraph_builder.rb +5 -6
- data/lib/uniword/builder/sdt_builder.rb +12 -4
- data/lib/uniword/builder/style_builder.rb +5 -5
- data/lib/uniword/builder/table_builder.rb +2 -2
- data/lib/uniword/builder/table_cell_builder.rb +3 -3
- data/lib/uniword/builder/table_row_builder.rb +2 -1
- data/lib/uniword/cli/comments_cli.rb +173 -0
- data/lib/uniword/cli/diff_cli.rb +60 -0
- data/lib/uniword/cli/main.rb +104 -0
- data/lib/uniword/cli/plain_text_command.rb +101 -0
- data/lib/uniword/cli/review_cli.rb +173 -34
- data/lib/uniword/cli/revisions_cli.rb +88 -0
- data/lib/uniword/cli/template_cli.rb +81 -0
- data/lib/uniword/converter/plain_text.rb +426 -0
- data/lib/uniword/converter/plain_text_result.rb +112 -0
- data/lib/uniword/converter.rb +11 -0
- data/lib/uniword/diff/document_differ.rb +2 -88
- data/lib/uniword/diff/lcs.rb +101 -0
- data/lib/uniword/diff/redline_result.rb +94 -0
- data/lib/uniword/diff/redliner.rb +319 -0
- data/lib/uniword/diff.rb +3 -0
- data/lib/uniword/docx/package_integrity_checker.rb +1 -1
- data/lib/uniword/errors.rb +33 -0
- data/lib/uniword/mhtml/word_css.rb +16 -8
- data/lib/uniword/ooxml/types/ooxml_boolean.rb +19 -7
- data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +4 -8
- data/lib/uniword/properties/boolean_element_factory.rb +48 -3
- data/lib/uniword/properties/boolean_formatting.rb +4 -0
- data/lib/uniword/properties/outline.rb +4 -13
- data/lib/uniword/quality/rules/image_alt_text_rule.rb +22 -43
- data/lib/uniword/review/review_manager.rb +128 -1
- data/lib/uniword/template/fill_result.rb +121 -0
- data/lib/uniword/template/filler.rb +292 -0
- data/lib/uniword/template.rb +2 -0
- data/lib/uniword/transformation/mhtml_element_renderer.rb +9 -3
- data/lib/uniword/transformation/ooxml_to_html_converter.rb +8 -3
- data/lib/uniword/validation/opc_validator.rb +3 -3
- data/lib/uniword/validation/validators/xml_schema_validator.rb +1 -1
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
- data/lib/uniword/wordprocessingml/document_root.rb +40 -6
- data/lib/uniword/wordprocessingml/drawing.rb +51 -0
- data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
- data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
- data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
- data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
- data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
- data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
- data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
- data/lib/uniword/wordprocessingml/style.rb +43 -40
- data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
- data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
- data/lib/uniword/wordprocessingml.rb +17 -4
- data/lib/uniword.rb +9 -0
- metadata +20 -6
|
@@ -4,12 +4,57 @@ require "lutaml/model"
|
|
|
4
4
|
|
|
5
5
|
module Uniword
|
|
6
6
|
module Properties
|
|
7
|
-
#
|
|
7
|
+
# Shared reading and writing behaviour for OOXML ST_OnOff elements.
|
|
8
|
+
#
|
|
8
9
|
# The val= setter must be defined AFTER attribute :val to override
|
|
9
|
-
# the generated setter.
|
|
10
|
+
# the generated setter, so includers pull in BooleanValSetter separately.
|
|
10
11
|
module BooleanElement
|
|
12
|
+
# Move a `value:` key onto `val:`.
|
|
13
|
+
#
|
|
14
|
+
# lutaml-model drops constructor keys it does not know, so
|
|
15
|
+
# `Bold.new(value: "0")` used to build an ON toggle and throw the
|
|
16
|
+
# argument away. An explicit `val:` still wins.
|
|
17
|
+
#
|
|
18
|
+
# @param attrs [Hash, Object] Constructor arguments
|
|
19
|
+
# @return [Hash, Object] Arguments with `value` renamed to `val`
|
|
20
|
+
def self.alias_value_key(attrs)
|
|
21
|
+
return attrs unless attrs.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
key = [:value, "value"].find { |k| attrs.key?(k) }
|
|
24
|
+
return attrs if key.nil?
|
|
25
|
+
|
|
26
|
+
attrs = attrs.dup
|
|
27
|
+
aliased = attrs.delete(key)
|
|
28
|
+
attrs[:val] = aliased unless attrs.key?(:val) || attrs.key?("val")
|
|
29
|
+
attrs
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(attrs = {})
|
|
33
|
+
super(BooleanElement.alias_value_key(attrs))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ST_OnOff (ECMA-376 §17.17.4) spells off as "0", "false" or "off".
|
|
37
|
+
# An absent w:val means the toggle is on. Unknown tokens read as on:
|
|
38
|
+
# a reader must not raise on a malformed document.
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean] true when the toggle is on
|
|
41
|
+
def on?
|
|
42
|
+
Ooxml::Types::OoxmlBoolean.on?(val)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One reading for every consumer.
|
|
46
|
+
#
|
|
47
|
+
# This used to be `val != "false"`, which read "0" and "off" as on.
|
|
48
|
+
# Word shows both as off.
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] true when the toggle is on
|
|
11
51
|
def value
|
|
12
|
-
|
|
52
|
+
on?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param new_value [Object] Any ST_OnOff spelling, or a Ruby boolean
|
|
56
|
+
def value=(new_value)
|
|
57
|
+
self.val = new_value
|
|
13
58
|
end
|
|
14
59
|
end
|
|
15
60
|
|
|
@@ -18,5 +18,9 @@ module Uniword
|
|
|
18
18
|
BooleanElementFactory.define("qFormat", "QuickFormat")
|
|
19
19
|
BooleanElementFactory.define("keepNext", "KeepNext")
|
|
20
20
|
BooleanElementFactory.define("keepLines", "KeepLines")
|
|
21
|
+
|
|
22
|
+
# Paragraph-level boolean elements
|
|
23
|
+
BooleanElementFactory.define("suppressLineNumbers", "SuppressLineNumbers")
|
|
24
|
+
BooleanElementFactory.define("bidi", "Bidi")
|
|
21
25
|
end
|
|
22
26
|
end
|
|
@@ -9,25 +9,16 @@ module Uniword
|
|
|
9
9
|
# Represents <w:outline/> or <w:outline w:val="false"/>
|
|
10
10
|
# Used in run properties (w:rPr) for outline text effect
|
|
11
11
|
class Outline < Lutaml::Model::Serializable
|
|
12
|
-
|
|
12
|
+
include BooleanElement
|
|
13
|
+
|
|
14
|
+
attribute :val, :string, default: nil
|
|
15
|
+
include BooleanValSetter
|
|
13
16
|
|
|
14
17
|
xml do
|
|
15
18
|
element "outline"
|
|
16
19
|
namespace Uniword::Ooxml::Namespaces::WordProcessingML
|
|
17
20
|
map_attribute "val", to: :val, render_nil: false, render_default: false
|
|
18
21
|
end
|
|
19
|
-
|
|
20
|
-
# Handle boolean-like values for val attribute
|
|
21
|
-
# nil = true (element present without val means true)
|
|
22
|
-
# 'false' = false
|
|
23
|
-
def initialize(attrs = {})
|
|
24
|
-
if [true, "true"].include?(attrs[:val])
|
|
25
|
-
attrs[:val] = nil # true = no val attribute
|
|
26
|
-
elsif [false, "false"].include?(attrs[:val])
|
|
27
|
-
attrs[:val] = "false"
|
|
28
|
-
end
|
|
29
|
-
super
|
|
30
|
-
end
|
|
31
22
|
end
|
|
32
23
|
end
|
|
33
24
|
end
|
|
@@ -33,55 +33,34 @@ module Uniword
|
|
|
33
33
|
|
|
34
34
|
return violations unless @require_alt_text
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
image_count += 1
|
|
36
|
+
# Same drawings the accessibility rule and the renderer see, table
|
|
37
|
+
# cells included, read through the same Drawing#alt_text.
|
|
38
|
+
document.images.each_with_index do |drawing, index|
|
|
39
|
+
image_count = index + 1
|
|
40
|
+
alt_text = drawing.alt_text
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
element: drawing,
|
|
61
|
-
)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
42
|
+
if alt_text.nil?
|
|
43
|
+
violations << create_violation(
|
|
44
|
+
severity: :error,
|
|
45
|
+
message: "Image #{image_count} is missing alt text. " \
|
|
46
|
+
"Alt text is required for accessibility.",
|
|
47
|
+
location: "Image #{image_count}",
|
|
48
|
+
element: drawing,
|
|
49
|
+
)
|
|
50
|
+
elsif alt_text.length < @min_length
|
|
51
|
+
violations << create_violation(
|
|
52
|
+
severity: :warning,
|
|
53
|
+
message: "Image #{image_count} has alt text that is too short " \
|
|
54
|
+
"(#{alt_text.length} characters, minimum: #{@min_length}). " \
|
|
55
|
+
"Provide more descriptive alt text.",
|
|
56
|
+
location: "Image #{image_count}",
|
|
57
|
+
element: drawing,
|
|
58
|
+
)
|
|
64
59
|
end
|
|
65
60
|
end
|
|
66
61
|
|
|
67
62
|
violations
|
|
68
63
|
end
|
|
69
|
-
|
|
70
|
-
private
|
|
71
|
-
|
|
72
|
-
# Extract alt text from image (Drawing element)
|
|
73
|
-
#
|
|
74
|
-
# In OOXML, alt text is stored in:
|
|
75
|
-
# - drawing.inline.doc_properties.descr (for inline images)
|
|
76
|
-
# - drawing.anchor.doc_properties.descr (for anchored images)
|
|
77
|
-
#
|
|
78
|
-
# @param drawing [Drawing] The drawing element to extract alt text from
|
|
79
|
-
# @return [String, nil] Alt text or nil if not present
|
|
80
|
-
def extract_alt_text(drawing)
|
|
81
|
-
# Try inline first, then anchor
|
|
82
|
-
doc_props = drawing&.inline&.doc_properties || drawing&.anchor&.doc_properties
|
|
83
|
-
doc_props&.descr
|
|
84
|
-
end
|
|
85
64
|
end
|
|
86
65
|
end
|
|
87
66
|
end
|
|
@@ -100,6 +100,45 @@ module Uniword
|
|
|
100
100
|
comment
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# Resolve a comment with Word semantics: remove the comment entry
|
|
104
|
+
# and every anchor marker referencing it (commentRangeStart,
|
|
105
|
+
# commentRangeEnd, and the commentReference run) from the body.
|
|
106
|
+
#
|
|
107
|
+
# @param comment_id [String] The comment ID to resolve
|
|
108
|
+
# @return [Uniword::Comment, nil] The removed comment
|
|
109
|
+
def resolve_comment!(comment_id)
|
|
110
|
+
comment = comments_part.find_comment(comment_id)
|
|
111
|
+
return nil unless comment
|
|
112
|
+
|
|
113
|
+
strip_comment_anchors(comment_id.to_s)
|
|
114
|
+
comments_part.remove_comment(comment_id)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Anchor a comment entry around a paragraph using Word's canonical
|
|
118
|
+
# markup (range start/end plus the styled reference run).
|
|
119
|
+
#
|
|
120
|
+
# @param comment_id [String] The comment ID to anchor
|
|
121
|
+
# @param paragraph [Wordprocessingml::Paragraph, nil] anchor target
|
|
122
|
+
# @return [void]
|
|
123
|
+
def anchor_comment(comment_id, paragraph)
|
|
124
|
+
Builder::CommentAnchorer.anchor(paragraph, comment_id)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Map of comment ID to the text of the paragraph anchoring it.
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash{String => String}] anchored paragraph text per ID;
|
|
130
|
+
# IDs without an anchor in the body are absent
|
|
131
|
+
def comment_anchor_texts
|
|
132
|
+
texts = {}
|
|
133
|
+
each_body_paragraph do |paragraph|
|
|
134
|
+
paragraph.comment_range_starts.each do |marker|
|
|
135
|
+
id = marker.id.to_s
|
|
136
|
+
texts[id] ||= paragraph.text
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
texts
|
|
140
|
+
end
|
|
141
|
+
|
|
103
142
|
# Remove a comment from the document.
|
|
104
143
|
#
|
|
105
144
|
# @param comment_id [String] The comment ID to remove
|
|
@@ -281,11 +320,99 @@ module Uniword
|
|
|
281
320
|
end
|
|
282
321
|
end
|
|
283
322
|
unless @resolver_hydrated
|
|
284
|
-
|
|
323
|
+
# The facade is shared on the document, so a second manager
|
|
324
|
+
# (or a second verb on the same manager chain) would re-run
|
|
325
|
+
# the resolver over already-hydrated entries and duplicate
|
|
326
|
+
# every revision. Hydrate only a facade that is still empty;
|
|
327
|
+
# a populated one is already in sync with the document.
|
|
328
|
+
@resolver.hydrate(@tracked_changes) if @tracked_changes.revisions.empty?
|
|
285
329
|
@resolver_hydrated = true
|
|
286
330
|
end
|
|
287
331
|
@tracked_changes
|
|
288
332
|
end
|
|
333
|
+
|
|
334
|
+
# --- Comment anchor removal ---------------------------------------
|
|
335
|
+
|
|
336
|
+
# Remove every anchor marker for a comment ID from the body,
|
|
337
|
+
# tables and SDT content included. Mirrors RevisionResolver's
|
|
338
|
+
# element_order surgery: parsed models keep serialization order in
|
|
339
|
+
# element_order, so each removed element's order entry goes with it.
|
|
340
|
+
#
|
|
341
|
+
# @param comment_id [String] The comment ID being resolved
|
|
342
|
+
# @return [void]
|
|
343
|
+
def strip_comment_anchors(comment_id)
|
|
344
|
+
each_body_paragraph do |paragraph|
|
|
345
|
+
remove_anchored_elements(paragraph, :comment_range_starts,
|
|
346
|
+
"commentRangeStart", comment_id)
|
|
347
|
+
remove_anchored_elements(paragraph, :comment_range_ends,
|
|
348
|
+
"commentRangeEnd", comment_id)
|
|
349
|
+
remove_reference_runs(paragraph, comment_id)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# @yieldparam paragraph [Wordprocessingml::Paragraph]
|
|
354
|
+
# @return [void]
|
|
355
|
+
def each_body_paragraph(&block)
|
|
356
|
+
containers = [@document.body].compact
|
|
357
|
+
FindReplace::ParagraphWalker.each_paragraph(containers, &block)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Remove the elements with the ID from a paragraph collection,
|
|
361
|
+
# keeping element_order in sync.
|
|
362
|
+
#
|
|
363
|
+
# @param paragraph [Wordprocessingml::Paragraph]
|
|
364
|
+
# @param collection_name [Symbol] reader of the marker collection
|
|
365
|
+
# @param tag [String] element_order tag of the marker
|
|
366
|
+
# @param comment_id [String] the comment ID being resolved
|
|
367
|
+
# @return [void]
|
|
368
|
+
def remove_anchored_elements(paragraph, collection_name, tag,
|
|
369
|
+
comment_id)
|
|
370
|
+
collection = paragraph.public_send(collection_name)
|
|
371
|
+
matching = collection.select { |element| element.id.to_s == comment_id }
|
|
372
|
+
matching.each do |element|
|
|
373
|
+
index = collection.index(element)
|
|
374
|
+
collection.delete(element)
|
|
375
|
+
remove_nth_order_entry(paragraph, tag, index)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Remove the runs carrying the comment's w:commentReference.
|
|
380
|
+
#
|
|
381
|
+
# @param paragraph [Wordprocessingml::Paragraph]
|
|
382
|
+
# @param comment_id [String] the comment ID being resolved
|
|
383
|
+
# @return [void]
|
|
384
|
+
def remove_reference_runs(paragraph, comment_id)
|
|
385
|
+
paragraph.runs.select do |run|
|
|
386
|
+
run.comment_reference&.id.to_s == comment_id
|
|
387
|
+
end.each do |run|
|
|
388
|
+
index = paragraph.runs.index(run)
|
|
389
|
+
paragraph.runs.delete(run)
|
|
390
|
+
remove_nth_order_entry(paragraph, "r", index)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Drop the index-th entry named `tag` from a model's element_order
|
|
395
|
+
# (no-op without a parsed order or when the entry is missing).
|
|
396
|
+
#
|
|
397
|
+
# @param model [Lutaml::Model::Serializable]
|
|
398
|
+
# @param tag [String]
|
|
399
|
+
# @param index [Integer, nil]
|
|
400
|
+
# @return [void]
|
|
401
|
+
def remove_nth_order_entry(model, tag, index)
|
|
402
|
+
order = Ooxml::ElementOrder.mutable_order(model)
|
|
403
|
+
return unless order && index
|
|
404
|
+
|
|
405
|
+
seen = -1
|
|
406
|
+
order.each_with_index do |entry, i|
|
|
407
|
+
next unless entry.name == tag
|
|
408
|
+
|
|
409
|
+
seen += 1
|
|
410
|
+
next unless seen == index
|
|
411
|
+
|
|
412
|
+
order.delete_at(i)
|
|
413
|
+
return
|
|
414
|
+
end
|
|
415
|
+
end
|
|
289
416
|
end
|
|
290
417
|
end
|
|
291
418
|
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
module Template
|
|
7
|
+
# Value object holding the result of a template fill operation.
|
|
8
|
+
#
|
|
9
|
+
# Counts filled placeholders per source kind (content controls,
|
|
10
|
+
# MERGEFIELD simple fields, {{var}} text placeholders) and records
|
|
11
|
+
# the variable names that had no data. Returned by `Filler#fill`.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# result = Filler.new(document).fill(data)
|
|
15
|
+
# puts result.summary
|
|
16
|
+
# puts result.to_json
|
|
17
|
+
class FillResult
|
|
18
|
+
# Placeholder source kinds, in fill precedence order.
|
|
19
|
+
SOURCES = %i[sdt mergefield text].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :filled, :missing_counts
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@filled = SOURCES.to_h { |kind| [kind, 0] }
|
|
25
|
+
@missing_counts = Hash.new { |hash, key| hash[key] = 0 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Record one filled placeholder of the given source kind.
|
|
29
|
+
#
|
|
30
|
+
# @param source [Symbol] one of SOURCES (e.g. :sdt, :mergefield, :text)
|
|
31
|
+
# @return [void]
|
|
32
|
+
def add(source)
|
|
33
|
+
@filled[source] += 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Record one occurrence of a variable with no data.
|
|
37
|
+
#
|
|
38
|
+
# @param name [String] the missing variable name
|
|
39
|
+
# @return [void]
|
|
40
|
+
def record_missing(name)
|
|
41
|
+
@missing_counts[name] += 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Total number of filled placeholders across all source kinds.
|
|
45
|
+
#
|
|
46
|
+
# @return [Integer]
|
|
47
|
+
def total_filled
|
|
48
|
+
@filled.values.sum
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Missing variable names, sorted alphabetically.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<String>]
|
|
54
|
+
def missing
|
|
55
|
+
@missing_counts.keys.sort
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether every placeholder found a value (nothing missing).
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def complete?
|
|
62
|
+
missing.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether no placeholder was filled at all.
|
|
66
|
+
#
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def empty?
|
|
69
|
+
total_filled.zero?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Human-readable summary of the fill operation.
|
|
73
|
+
#
|
|
74
|
+
# @return [String]
|
|
75
|
+
def summary
|
|
76
|
+
parts = SOURCES.filter_map do |kind|
|
|
77
|
+
count = @filled[kind]
|
|
78
|
+
"#{count} #{kind} placeholder(s)" if count.positive?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
summary = if parts.empty?
|
|
82
|
+
"No placeholders filled."
|
|
83
|
+
else
|
|
84
|
+
filled_summary(parts)
|
|
85
|
+
end
|
|
86
|
+
summary += missing_summary if missing.any?
|
|
87
|
+
summary
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Serialize to JSON string.
|
|
91
|
+
#
|
|
92
|
+
# @return [String] Pretty-printed JSON
|
|
93
|
+
def to_json(*_args)
|
|
94
|
+
JSON.pretty_generate(to_h)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Convert to a plain Hash suitable for serialization.
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash]
|
|
100
|
+
def to_h
|
|
101
|
+
{
|
|
102
|
+
filled: @filled.dup,
|
|
103
|
+
total_filled: total_filled,
|
|
104
|
+
missing: missing,
|
|
105
|
+
missing_counts: @missing_counts.dup,
|
|
106
|
+
summary: summary,
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def filled_summary(parts)
|
|
113
|
+
"Filled #{total_filled} placeholder(s) (#{parts.join(', ')})."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def missing_summary
|
|
117
|
+
" Missing variable(s) left untouched: #{missing.join(', ')}."
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|