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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/lib/uniword/accessibility/rules/image_alt_text_rule.rb +36 -13
  4. data/lib/uniword/builder/image_builder.rb +2 -0
  5. data/lib/uniword/builder/paragraph_builder.rb +5 -6
  6. data/lib/uniword/builder/sdt_builder.rb +12 -4
  7. data/lib/uniword/builder/style_builder.rb +5 -5
  8. data/lib/uniword/builder/table_builder.rb +2 -2
  9. data/lib/uniword/builder/table_cell_builder.rb +3 -3
  10. data/lib/uniword/builder/table_row_builder.rb +2 -1
  11. data/lib/uniword/cli/comments_cli.rb +173 -0
  12. data/lib/uniword/cli/diff_cli.rb +60 -0
  13. data/lib/uniword/cli/main.rb +104 -0
  14. data/lib/uniword/cli/plain_text_command.rb +101 -0
  15. data/lib/uniword/cli/review_cli.rb +173 -34
  16. data/lib/uniword/cli/revisions_cli.rb +88 -0
  17. data/lib/uniword/cli/template_cli.rb +81 -0
  18. data/lib/uniword/converter/plain_text.rb +426 -0
  19. data/lib/uniword/converter/plain_text_result.rb +112 -0
  20. data/lib/uniword/converter.rb +11 -0
  21. data/lib/uniword/diff/document_differ.rb +2 -88
  22. data/lib/uniword/diff/lcs.rb +101 -0
  23. data/lib/uniword/diff/redline_result.rb +94 -0
  24. data/lib/uniword/diff/redliner.rb +319 -0
  25. data/lib/uniword/diff.rb +3 -0
  26. data/lib/uniword/docx/package_integrity_checker.rb +1 -1
  27. data/lib/uniword/errors.rb +33 -0
  28. data/lib/uniword/mhtml/word_css.rb +16 -8
  29. data/lib/uniword/ooxml/types/ooxml_boolean.rb +19 -7
  30. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +4 -8
  31. data/lib/uniword/properties/boolean_element_factory.rb +48 -3
  32. data/lib/uniword/properties/boolean_formatting.rb +4 -0
  33. data/lib/uniword/properties/outline.rb +4 -13
  34. data/lib/uniword/quality/rules/image_alt_text_rule.rb +22 -43
  35. data/lib/uniword/review/review_manager.rb +128 -1
  36. data/lib/uniword/template/fill_result.rb +121 -0
  37. data/lib/uniword/template/filler.rb +292 -0
  38. data/lib/uniword/template.rb +2 -0
  39. data/lib/uniword/transformation/mhtml_element_renderer.rb +9 -3
  40. data/lib/uniword/transformation/ooxml_to_html_converter.rb +8 -3
  41. data/lib/uniword/validation/opc_validator.rb +3 -3
  42. data/lib/uniword/validation/validators/xml_schema_validator.rb +1 -1
  43. data/lib/uniword/version.rb +1 -1
  44. data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
  45. data/lib/uniword/wordprocessingml/document_root.rb +40 -6
  46. data/lib/uniword/wordprocessingml/drawing.rb +51 -0
  47. data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
  48. data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
  49. data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
  50. data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
  51. data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
  52. data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
  53. data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
  54. data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
  55. data/lib/uniword/wordprocessingml/style.rb +43 -40
  56. data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
  57. data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
  58. data/lib/uniword/wordprocessingml.rb +17 -4
  59. data/lib/uniword.rb +9 -0
  60. metadata +20 -6
@@ -4,12 +4,57 @@ require "lutaml/model"
4
4
 
5
5
  module Uniword
6
6
  module Properties
7
- # OOXML boolean element mixin for value/val getter logic only.
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
- val != "false"
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
- attribute :val, :string
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
- image_count = 0
37
- document.paragraphs.each_with_index do |para, para_index|
38
- # Images are in runs as drawings
39
- para.runs.each do |run|
40
- run.drawings.each do |drawing|
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
- alt_text = extract_alt_text(drawing)
44
-
45
- if alt_text.nil? || alt_text.empty?
46
- violations << create_violation(
47
- severity: :error,
48
- message: "Image #{image_count} is missing alt text. " \
49
- "Alt text is required for accessibility.",
50
- location: "Paragraph #{para_index + 1}, Image #{image_count}",
51
- element: drawing,
52
- )
53
- elsif alt_text.length < @min_length
54
- violations << create_violation(
55
- severity: :warning,
56
- message: "Image #{image_count} has alt text that is too short " \
57
- "(#{alt_text.length} characters, minimum: #{@min_length}). " \
58
- "Provide more descriptive alt text.",
59
- location: "Paragraph #{para_index + 1}, Image #{image_count}",
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
- @resolver.hydrate(@tracked_changes)
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