uniword 1.7.1 → 1.7.3

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 (58) 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 +425 -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/errors.rb +33 -0
  27. data/lib/uniword/mhtml/word_css.rb +16 -8
  28. data/lib/uniword/omml_integration.rb +62 -0
  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/version.rb +1 -1
  42. data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
  43. data/lib/uniword/wordprocessingml/document_root.rb +40 -6
  44. data/lib/uniword/wordprocessingml/drawing.rb +51 -0
  45. data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
  46. data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
  47. data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
  48. data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
  49. data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
  50. data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
  51. data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
  52. data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
  53. data/lib/uniword/wordprocessingml/style.rb +43 -40
  54. data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
  55. data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
  56. data/lib/uniword/wordprocessingml.rb +17 -4
  57. data/lib/uniword.rb +19 -0
  58. metadata +21 -6
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Cli
5
+ # Mechanics for the top-level `to` / `from` CLI verbs (issue #80):
6
+ # format validation, document loading, conversion through
7
+ # Converter::PlainText, default output path derivation, and output
8
+ # writing. Presentation stays in CLI so in-process specs see the
9
+ # same output as the executable.
10
+ module PlainTextCommand
11
+ FORMATS = { "md" => :markdown, "adoc" => :asciidoc }.freeze
12
+
13
+ EXTENSIONS = { markdown: ".md", asciidoc: ".adoc" }.freeze
14
+
15
+ module_function
16
+
17
+ # Convert a DOCX file to a plain-text format.
18
+ #
19
+ # @param format_key [String] "md" or "adoc"
20
+ # @param input_path [String] path to the .docx input
21
+ # @param strict [Boolean] fail on fidelity loss
22
+ # @return [Converter::PlainTextResult]
23
+ def convert_to(format_key, input_path, strict: false)
24
+ format = normalize_format!(format_key)
25
+ document = load_document!(input_path)
26
+ converter = Converter::PlainText.new
27
+ if format == :markdown
28
+ converter.to_markdown_result(document, strict: strict)
29
+ else
30
+ converter.to_asciidoc_result(document, strict: strict)
31
+ end
32
+ end
33
+
34
+ # Convert a plain-text file to a DocumentRoot.
35
+ #
36
+ # @param format_key [String] "md" or "adoc"
37
+ # @param input_path [String] path to the .md/.adoc input
38
+ # @param strict [Boolean] fail on fidelity loss
39
+ # @return [Converter::PlainTextResult]
40
+ def convert_from(format_key, input_path, strict: false)
41
+ format = normalize_format!(format_key)
42
+ raise FileNotFoundError.new(input_path) unless File.exist?(input_path)
43
+
44
+ text = File.read(input_path)
45
+ converter = Converter::PlainText.new
46
+ if format == :markdown
47
+ converter.from_markdown_result(text, strict: strict)
48
+ else
49
+ converter.from_asciidoc_result(text, strict: strict)
50
+ end
51
+ end
52
+
53
+ # @param format_key [String] "md" or "adoc"
54
+ # @return [String] the canonical file extension (".md"/".adoc")
55
+ # @raise [UnsupportedOperationError] for unknown formats
56
+ def extension_for(format_key)
57
+ format = normalize_format!(format_key)
58
+ EXTENSIONS.fetch(format)
59
+ end
60
+
61
+ # Default output path: the input's basename with a new
62
+ # extension, written next to the input.
63
+ #
64
+ # @param input_path [String]
65
+ # @param extension [String] e.g. ".md" or ".docx"
66
+ # @return [String]
67
+ def default_output(input_path, extension)
68
+ directory = File.dirname(input_path)
69
+ basename = File.basename(input_path, ".*")
70
+ File.join(directory, "#{basename}#{extension}")
71
+ end
72
+
73
+ # Write converted text to a file, or to stdout for "-".
74
+ #
75
+ # @param result [Converter::PlainTextResult]
76
+ # @param output_path [String] path or "-"
77
+ def write_text(result, output_path)
78
+ if output_path == "-"
79
+ $stdout.puts result.text
80
+ else
81
+ File.write(output_path, result.text)
82
+ end
83
+ end
84
+
85
+ def normalize_format!(format_key)
86
+ FORMATS.fetch(format_key.to_s.downcase) do
87
+ raise UnsupportedOperationError.new(
88
+ "format #{format_key.inspect}",
89
+ "supported: #{FORMATS.keys.join(', ')}",
90
+ )
91
+ end
92
+ end
93
+
94
+ def load_document!(path)
95
+ raise FileNotFoundError.new(path) unless File.exist?(path)
96
+
97
+ DocumentFactory.from_file(path)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
+ require "time"
5
+ require "date"
4
6
 
5
7
  module Uniword
6
8
  # Review subcommands for Uniword CLI.
@@ -23,9 +25,12 @@ module Uniword
23
25
  Examples:
24
26
  $ uniword review comments document.docx
25
27
  $ uniword review comments document.docx --verbose
28
+ $ uniword review comments document.docx --json
26
29
  DESC
27
30
  option :verbose, aliases: "-v", desc: "Show detailed information",
28
31
  type: :boolean, default: false
32
+ option :json, desc: "Output as JSON (machine-readable)",
33
+ type: :boolean, default: false
29
34
  def comments(path)
30
35
  doc = load_document(path)
31
36
  manager = Review::ReviewManager.new(doc)
@@ -36,6 +41,21 @@ module Uniword
36
41
  return
37
42
  end
38
43
 
44
+ if options[:json]
45
+ require "json"
46
+ anchors = manager.comment_anchor_texts
47
+ puts JSON.pretty_generate(comment_list.map do |comment|
48
+ {
49
+ id: comment.comment_id,
50
+ author: comment.author,
51
+ date: comment.date,
52
+ text: comment.text,
53
+ anchored_text: anchors[comment.comment_id.to_s],
54
+ }
55
+ end)
56
+ return
57
+ end
58
+
39
59
  say "Comments (#{comment_list.count}):", :green
40
60
  comment_list.each_with_index do |comment, idx|
41
61
  say " #{idx + 1}. ID: #{comment.comment_id}"
@@ -64,12 +84,19 @@ module Uniword
64
84
  $ uniword review changes document.docx
65
85
  $ uniword review changes document.docx --author "John Doe"
66
86
  $ uniword review changes document.docx --type insert
87
+ $ uniword review changes document.docx --since 2026-01-01 --json
67
88
  DESC
68
89
  option :author, desc: "Filter revisions by author", type: :string
69
90
  option :type, desc: "Filter by type (insert/delete/format_change)",
70
91
  type: :string
92
+ option :since, desc: "Only revisions dated on/after (ISO 8601)",
93
+ type: :string
94
+ option :until, desc: "Only revisions dated on/before (ISO 8601)",
95
+ type: :string
71
96
  option :verbose, aliases: "-v", desc: "Show detailed information",
72
97
  type: :boolean, default: false
98
+ option :json, desc: "Output as JSON (machine-readable)",
99
+ type: :boolean, default: false
73
100
  def changes(path)
74
101
  doc = load_document(path)
75
102
  manager = Review::ReviewManager.new(doc)
@@ -81,6 +108,20 @@ module Uniword
81
108
  return
82
109
  end
83
110
 
111
+ if options[:json]
112
+ require "json"
113
+ puts JSON.pretty_generate(revisions.map do |rev|
114
+ {
115
+ id: rev.revision_id,
116
+ author: rev.author,
117
+ date: rev.date,
118
+ type: rev.type.to_s,
119
+ text: rev.text,
120
+ }
121
+ end)
122
+ return
123
+ end
124
+
84
125
  say "Tracked changes (#{revisions.count}):", :green
85
126
  revisions.each_with_index do |rev, idx|
86
127
  say " #{idx + 1}. ID: #{rev.revision_id}"
@@ -102,58 +143,53 @@ module Uniword
102
143
  handle_error(e)
103
144
  end
104
145
 
105
- desc "accept FILE REVISION_ID", "Accept a single revision"
146
+ desc "accept FILE [REVISION_ID]",
147
+ "Accept a revision, or all matching filters"
106
148
  long_desc <<~DESC
107
- Accept a tracked change by its revision ID.
149
+ Accept a tracked change by its revision ID. Without an ID, every
150
+ revision matching the --author/--since/--until filters is accepted
151
+ (default: all).
108
152
 
109
153
  Examples:
110
154
  $ uniword review accept document.docx 3
111
155
  $ uniword review accept document.docx 3 --output accepted.docx
156
+ $ uniword review accept document.docx --author "John Doe" -o out.docx
157
+ $ uniword review accept document.docx --since 2026-01-01 -o out.docx
112
158
  DESC
159
+ option :author, desc: "With no ID: only revisions by this author",
160
+ type: :string
161
+ option :since, desc: "With no ID: only revisions dated on/after",
162
+ type: :string
163
+ option :until, desc: "With no ID: only revisions dated on/before",
164
+ type: :string
113
165
  option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
114
166
  type: :string
115
- def accept(path, revision_id)
116
- doc = load_document(path)
117
- manager = Review::ReviewManager.new(doc)
118
-
119
- unless manager.accept(revision_id)
120
- say "Revision '#{revision_id}' not found.", :red
121
- exit 1
122
- end
123
-
124
- save_output(doc, path)
125
- say "Accepted revision '#{revision_id}'.", :green
126
- rescue Uniword::Error => e
127
- handle_error(e)
128
- rescue StandardError => e
129
- handle_error(e)
167
+ def accept(path, revision_id = nil)
168
+ apply_decisions(path, revision_id, :accept, "Accepted")
130
169
  end
131
170
 
132
- desc "reject FILE REVISION_ID", "Reject a single revision"
171
+ desc "reject FILE [REVISION_ID]",
172
+ "Reject a revision, or all matching filters"
133
173
  long_desc <<~DESC
134
- Reject a tracked change by its revision ID.
174
+ Reject a tracked change by its revision ID. Without an ID, every
175
+ revision matching the --author/--since/--until filters is rejected
176
+ (default: all).
135
177
 
136
178
  Examples:
137
179
  $ uniword review reject document.docx 3
138
180
  $ uniword review reject document.docx 3 --output rejected.docx
181
+ $ uniword review reject document.docx --author "John Doe" -o out.docx
139
182
  DESC
183
+ option :author, desc: "With no ID: only revisions by this author",
184
+ type: :string
185
+ option :since, desc: "With no ID: only revisions dated on/after",
186
+ type: :string
187
+ option :until, desc: "With no ID: only revisions dated on/before",
188
+ type: :string
140
189
  option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
141
190
  type: :string
142
- def reject(path, revision_id)
143
- doc = load_document(path)
144
- manager = Review::ReviewManager.new(doc)
145
-
146
- unless manager.reject(revision_id)
147
- say "Revision '#{revision_id}' not found.", :red
148
- exit 1
149
- end
150
-
151
- save_output(doc, path)
152
- say "Rejected revision '#{revision_id}'.", :green
153
- rescue Uniword::Error => e
154
- handle_error(e)
155
- rescue StandardError => e
156
- handle_error(e)
191
+ def reject(path, revision_id = nil)
192
+ apply_decisions(path, revision_id, :reject, "Rejected")
157
193
  end
158
194
 
159
195
  desc "accept-all FILE", "Accept all tracked changes"
@@ -277,6 +313,60 @@ module Uniword
277
313
 
278
314
  private
279
315
 
316
+ # Apply accept/reject to one revision ID or to all revisions
317
+ # matching the author/date filters, then save.
318
+ #
319
+ # @param path [String] document path
320
+ # @param revision_id [String, nil] single target ID (filters unused)
321
+ # @param action [Symbol] :accept or :reject
322
+ # @param label [String] past-tense verb for the report line
323
+ # @return [void]
324
+ def apply_decisions(path, revision_id, action, label)
325
+ doc = load_document(path)
326
+ manager = Review::ReviewManager.new(doc)
327
+
328
+ if revision_id
329
+ found = decide(manager, action, revision_id)
330
+ unless found
331
+ say "Revision '#{revision_id}' not found.", :red
332
+ exit 1
333
+ end
334
+ count = 1
335
+ else
336
+ targets = filtered_revisions(manager)
337
+ # Capture the count first: deciding a revision splices it out
338
+ # of the facade's list, which `targets` may alias.
339
+ count = targets.count
340
+ targets.each { |rev| decide(manager, action, rev.revision_id) }
341
+ end
342
+
343
+ save_output(doc, path)
344
+
345
+ if count.positive?
346
+ say "#{label} #{count} revision(s).", :green
347
+ else
348
+ say "No matching revisions found.", :yellow
349
+ end
350
+ rescue Uniword::Error => e
351
+ handle_error(e)
352
+ rescue StandardError => e
353
+ handle_error(e)
354
+ end
355
+
356
+ # Dispatch one revision decision to the review manager.
357
+ #
358
+ # @param manager [Review::ReviewManager]
359
+ # @param action [Symbol] :accept or :reject
360
+ # @param revision_id [String]
361
+ # @return [Boolean, nil] outcome reported by the manager
362
+ def decide(manager, action, revision_id)
363
+ if action == :accept
364
+ manager.accept(revision_id)
365
+ else
366
+ manager.reject(revision_id)
367
+ end
368
+ end
369
+
280
370
  # Filter revisions based on CLI options.
281
371
  #
282
372
  # @param manager [Review::ReviewManager] The review manager
@@ -290,6 +380,9 @@ module Uniword
290
380
  end
291
381
  end
292
382
 
383
+ revisions = filter_by_date_range(revisions, options[:since],
384
+ options[:until])
385
+
293
386
  if options[:type]
294
387
  type = options[:type].to_sym
295
388
  revisions = revisions.select { |r| r.type == type }
@@ -298,6 +391,52 @@ module Uniword
298
391
  revisions
299
392
  end
300
393
 
394
+ # Keep revisions whose date falls within [since, until]. Dates are
395
+ # ISO 8601; unparseable revision dates are dropped when a date
396
+ # filter is active. Date-only bounds are interpreted as UTC days
397
+ # (inclusive on both ends).
398
+ #
399
+ # @param revisions [Array<Uniword::Revision>]
400
+ # @param since [String, nil] inclusive lower bound (ISO 8601)
401
+ # @param until_date [String, nil] inclusive upper bound (ISO 8601)
402
+ # @return [Array<Uniword::Revision>]
403
+ def filter_by_date_range(revisions, since, until_date)
404
+ lower = parse_filter_time(since)
405
+ upper = parse_filter_time(until_date, end_of_day: true)
406
+ return revisions unless lower || upper
407
+
408
+ revisions.select do |revision|
409
+ date = parse_filter_time(revision.date)
410
+ next false unless date
411
+
412
+ (lower.nil? || date >= lower) && (upper.nil? || date <= upper)
413
+ end
414
+ end
415
+
416
+ # Parse an ISO 8601 timestamp or date (2026-01-31 or
417
+ # 2026-01-31T12:00:00Z); nil when blank, exit 1 when unparseable.
418
+ # Date-only values resolve to the start of the UTC day, or to the
419
+ # last second of that day with end_of_day.
420
+ #
421
+ # @param value [String, nil]
422
+ # @param end_of_day [Boolean] resolve date-only values to 23:59:59Z
423
+ # @return [Time, nil]
424
+ def parse_filter_time(value, end_of_day: false)
425
+ return nil if value.nil? || value.to_s.empty?
426
+
427
+ Time.iso8601(value.to_s)
428
+ rescue ArgumentError
429
+ date = begin
430
+ Date.iso8601(value.to_s)
431
+ rescue ArgumentError
432
+ say "Invalid date: '#{value}' (expected ISO 8601, " \
433
+ "e.g. 2026-01-31 or 2026-01-31T12:00:00Z)", :red
434
+ exit 1
435
+ end
436
+ start_of_day = Time.utc(date.year, date.month, date.day)
437
+ end_of_day ? start_of_day + 86_399 : start_of_day
438
+ end
439
+
301
440
  # Format revision type for display.
302
441
  #
303
442
  # @param type [Symbol] The revision type
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Uniword
6
+ # Revisions subcommands for the Uniword CLI.
7
+ #
8
+ # The issue-#79 spelling of the tracked-changes verbs: `revisions
9
+ # list` / `revisions accept` / `revisions reject` delegate to the
10
+ # `review changes|accept|reject` implementations, so both groups
11
+ # share one behavior surface (filters, JSON, exit codes).
12
+ class RevisionsCLI < Thor
13
+ include CLIHelpers
14
+
15
+ desc "list FILE", "List tracked changes (alias of review changes)"
16
+ long_desc <<~DESC
17
+ List tracked changes with id, type, author, date, and text -
18
+ identical to `uniword review changes`, under the revisions name.
19
+
20
+ Examples:
21
+ $ uniword revisions list document.docx
22
+ $ uniword revisions list document.docx --author "John Doe" --json
23
+ $ uniword revisions list document.docx --since 2026-01-01
24
+ DESC
25
+ option :author, desc: "Filter revisions by author", type: :string
26
+ option :type, desc: "Filter by type (insert/delete/format_change)",
27
+ type: :string
28
+ option :since, desc: "Only revisions dated on/after (ISO 8601)",
29
+ type: :string
30
+ option :until, desc: "Only revisions dated on/before (ISO 8601)",
31
+ type: :string
32
+ option :verbose, aliases: "-v", desc: "Show detailed information",
33
+ type: :boolean, default: false
34
+ option :json, desc: "Output as JSON (machine-readable)",
35
+ type: :boolean, default: false
36
+ def list(path)
37
+ ReviewCLI.new.invoke(:changes, [path], options.to_h)
38
+ end
39
+
40
+ desc "accept FILE [REVISION_ID]",
41
+ "Accept revisions (alias of review accept)"
42
+ long_desc <<~DESC
43
+ Accept one revision by id, or every filter-matching revision when
44
+ no id is given. Identical to `uniword review accept`.
45
+
46
+ Examples:
47
+ $ uniword revisions accept document.docx
48
+ $ uniword revisions accept document.docx 4
49
+ $ uniword revisions accept document.docx --author "John Doe" -o out.docx
50
+ DESC
51
+ option :author, desc: "With no ID: only revisions by this author",
52
+ type: :string
53
+ option :since, desc: "With no ID: only revisions dated on/after",
54
+ type: :string
55
+ option :until, desc: "With no ID: only revisions dated on/before",
56
+ type: :string
57
+ option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
58
+ type: :string
59
+ def accept(path, revision_id = nil)
60
+ ReviewCLI.new.invoke(:accept, [path, revision_id].compact,
61
+ options.to_h)
62
+ end
63
+
64
+ desc "reject FILE [REVISION_ID]",
65
+ "Reject revisions (alias of review reject)"
66
+ long_desc <<~DESC
67
+ Reject one revision by id, or every filter-matching revision when
68
+ no id is given. Identical to `uniword review reject`.
69
+
70
+ Examples:
71
+ $ uniword revisions reject document.docx
72
+ $ uniword revisions reject document.docx 4
73
+ $ uniword revisions reject document.docx --author "John Doe" -o out.docx
74
+ DESC
75
+ option :author, desc: "With no ID: only revisions by this author",
76
+ type: :string
77
+ option :since, desc: "With no ID: only revisions dated on/after",
78
+ type: :string
79
+ option :until, desc: "With no ID: only revisions dated on/before",
80
+ type: :string
81
+ option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
82
+ type: :string
83
+ def reject(path, revision_id = nil)
84
+ ReviewCLI.new.invoke(:reject, [path, revision_id].compact,
85
+ options.to_h)
86
+ end
87
+ end
88
+ end
@@ -151,8 +151,83 @@ module Uniword
151
151
  handle_error(e, verbose: options[:verbose])
152
152
  end
153
153
 
154
+ desc "fill DOCUMENT", "Fill placeholders in a document with data"
155
+ long_desc <<~DESC
156
+ Fill placeholders in a DOCX document (mail merge) and save the
157
+ result as a new DOCX file.
158
+
159
+ Placeholder sources, in precedence order:
160
+ 1. Content controls (w:sdt) whose alias or tag names a variable
161
+ 2. MERGEFIELD simple fields (w:fldSimple)
162
+ 3. {{var}} text placeholders inside run text
163
+
164
+ Data is loaded from a YAML or JSON file (--data, required). Nested
165
+ data keys are addressed with dotted paths ({{client.name}}).
166
+ Placeholders without data are left untouched unless --strict,
167
+ which fails with the list of missing variable names.
168
+
169
+ Examples:
170
+ $ uniword template fill letter.docx --data data.yml
171
+ $ uniword template fill letter.docx --data data.json -o out.docx
172
+ $ uniword template fill letter.docx --data data.yml --strict
173
+ $ uniword template fill letter.docx --data data.yml --json
174
+ DESC
175
+ option :data, desc: "Path to YAML or JSON data file", type: :string,
176
+ required: true
177
+ option :output, aliases: "-o",
178
+ desc: "Output DOCX path (default: <name>.filled.docx)",
179
+ type: :string
180
+ option :strict, desc: "Fail when a placeholder variable has no data",
181
+ type: :boolean, default: false
182
+ option :json, desc: "Report as JSON", type: :boolean, default: false
183
+ option :verbose, aliases: "-v", desc: "Verbose output",
184
+ type: :boolean, default: false
185
+ def fill(input_path)
186
+ validate_input_file(input_path)
187
+
188
+ document = DocumentFactory.from_file(input_path)
189
+ data = parse_data_file(options[:data])
190
+ log_fill_start(data) if options[:verbose]
191
+ report_fill(document, data, input_path)
192
+ rescue Uniword::Error => e
193
+ handle_error(e)
194
+ rescue StandardError => e
195
+ handle_error(e, verbose: options[:verbose])
196
+ end
197
+
154
198
  private
155
199
 
200
+ def validate_input_file(path)
201
+ return if File.exist?(path)
202
+
203
+ say "File not found: #{path}", :red
204
+ exit 1
205
+ end
206
+
207
+ def log_fill_start(data)
208
+ return unless data.any?
209
+
210
+ say " Data keys: #{data.keys.join(', ')}", :cyan
211
+ end
212
+
213
+ def report_fill(document, data, input_path)
214
+ result = fill_document(document, data)
215
+ output_path = options[:output] || derive_filled_path(input_path)
216
+ document.save(output_path)
217
+
218
+ if options[:json]
219
+ puts result.to_json
220
+ else
221
+ say result.summary, result.complete? ? :green : :yellow
222
+ say "Filled document -> #{output_path}", :green
223
+ end
224
+ end
225
+
226
+ def fill_document(document, data)
227
+ Uniword::Template::Filler.new(document)
228
+ .fill(data, strict: options[:strict])
229
+ end
230
+
156
231
  def load_template_data
157
232
  data = {}
158
233
 
@@ -189,5 +264,11 @@ module Uniword
189
264
 
190
265
  data
191
266
  end
267
+
268
+ # Default output path: "<name>.filled.docx" next to the input.
269
+ def derive_filled_path(path)
270
+ ext = File.extname(path)
271
+ "#{path.delete_suffix(ext)}.filled#{ext}"
272
+ end
192
273
  end
193
274
  end