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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34ddbf784b5b85ba8aedc6b4df9b255763e2b8014bcdea0124563e261555b32b
4
- data.tar.gz: e4ef3fce0529a6db2642cbf547c28628512834339ce636f15d90c30dccba7eb2
3
+ metadata.gz: 7d3fa44e3714e442378ed0314c1513b673aef06059b5d02ddf92e91ea37fdca2
4
+ data.tar.gz: 7cddc2e3d00bd3c17b34967746642592f7ecd6bcc1fb224267c6abea92025a52
5
5
  SHA512:
6
- metadata.gz: ae06c93c3b13ffffc70b0bddc7137945e4a035f2a4f75e315406547166c4423cdfe8ff8835b47ae5df50be9c6a021f500b89bd17512ae646cb3dd4c890bf8d8b
7
- data.tar.gz: 7a37bcf5e4cc1ece3a92ad962757a48613be7f960890bd52017dcf8343bb1989172c7b9bd931a384dfe4bcee58c24de27b80b76e9dd4d3eca09280b078f8e3f1
6
+ metadata.gz: 68694569d14d4bef307578bb562dba32e5ef512d94d3dab303fac9afce6e078a3d550531fa96250228aece9b9cf687bd2aab2957c9272fd6a383ca4d6f945a85
7
+ data.tar.gz: abd7e230281b420942921792d1711647f6cf9f26e6e129570ef6cad8c4a680d63c0fbd2334115cd44399ec03012172dd553cbd854f6ad6ed181ae8f0197d50f6
data/CHANGELOG.md CHANGED
@@ -30,6 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
30
 
31
31
  ### Changed
32
32
 
33
+ - **Breaking:** `ParagraphProperties#spacing` now returns an Array of
34
+ `Properties::Spacing` (was a single `Spacing` or nil), because Word
35
+ can emit more than one `w:spacing` inside a single `w:pPr`. Use
36
+ `ensure_spacing` to mutate spacing fields and the `Style` readers
37
+ (`spacing_before`/`spacing_after`) to read them. The readers resolve
38
+ conflicting values last-wins, matching Word.
33
39
  - `Uniword::Diff` module now autoloads `Semantic` (element-level
34
40
  diff sits alongside the existing text-level `DocumentDiffer`).
35
41
  - `Uniword::Batch` module now autoloads `Operation` (sibling to
@@ -18,14 +18,15 @@ module Uniword
18
18
  violations = []
19
19
 
20
20
  document.images.each_with_index do |image, index|
21
- # Check for alt text existence
22
- if image.alt_text.nil? || image.alt_text.strip.empty?
21
+ alt_text = image.alt_text
22
+
23
+ # Drawing#alt_text already treats a blank descr as absent.
24
+ if alt_text.nil?
23
25
  violations << create_violation(
24
26
  message: "Image #{index + 1} missing alternative text",
25
27
  element: image,
26
28
  severity: @config[:severity] || :error,
27
- suggestion: @config[:suggestion] ||
28
- "Add descriptive alternative text using image.alt_text = '...'",
29
+ suggestion: missing_alt_text_suggestion(image),
29
30
  )
30
31
  next # Skip quality checks if no alt text
31
32
  end
@@ -33,7 +34,7 @@ module Uniword
33
34
  # Check alt text quality if enabled
34
35
  next unless @config[:check_quality]
35
36
 
36
- violations.concat(check_alt_text_quality(image, index))
37
+ violations.concat(check_alt_text_quality(image, alt_text, index))
37
38
  end
38
39
 
39
40
  violations
@@ -41,18 +42,39 @@ module Uniword
41
42
 
42
43
  private
43
44
 
45
+ # Word's older Alt Text dialog had both a Title and a Description
46
+ # field, and templates from that era put the description in Title.
47
+ # Title is a caption, not a text alternative, so the image still
48
+ # counts as undescribed — but say where the text already is.
49
+ #
50
+ # @param drawing [Wordprocessingml::Drawing] Drawing being reported
51
+ # @return [String] Suggestion text
52
+ def missing_alt_text_suggestion(drawing)
53
+ title = drawing.alt_title
54
+ if title
55
+ return "Move the drawing's docPr title (#{title.inspect}) into " \
56
+ "its descr attribute; title is a caption, not alt text"
57
+ end
58
+
59
+ @config[:suggestion] ||
60
+ "Add descriptive alternative text via the drawing's " \
61
+ "docPr descr attribute"
62
+ end
63
+
44
64
  # Check quality of alt text
45
65
  #
46
- # @param image [Image] Image to check
66
+ # @param image [Wordprocessingml::Drawing] Drawing to check
67
+ # @param alt_text [String] Alternative text read from the drawing
47
68
  # @param index [Integer] Image index
48
69
  # @return [Array<AccessibilityViolation>] Quality violations
49
- def check_alt_text_quality(image, index)
70
+ def check_alt_text_quality(image, alt_text, index)
50
71
  violations = []
51
72
 
52
73
  # Check minimum length
53
- if @config[:min_length] && image.alt_text.length < @config[:min_length]
74
+ if @config[:min_length] && alt_text.length < @config[:min_length]
54
75
  violations << create_violation(
55
- message: "Image #{index + 1} has insufficient alt text (too short: #{image.alt_text.length} chars)",
76
+ message: "Image #{index + 1} has insufficient alt text " \
77
+ "(too short: #{alt_text.length} chars)",
56
78
  element: image,
57
79
  severity: :warning,
58
80
  suggestion: "Alternative text should describe the image content meaningfully (min #{@config[:min_length]} chars)",
@@ -60,9 +82,10 @@ module Uniword
60
82
  end
61
83
 
62
84
  # Check maximum length
63
- if @config[:max_length] && image.alt_text.length > @config[:max_length]
85
+ if @config[:max_length] && alt_text.length > @config[:max_length]
64
86
  violations << create_violation(
65
- message: "Image #{index + 1} has excessive alt text (too long: #{image.alt_text.length} chars)",
87
+ message: "Image #{index + 1} has excessive alt text " \
88
+ "(too long: #{alt_text.length} chars)",
66
89
  element: image,
67
90
  severity: :warning,
68
91
  suggestion: "Keep alternative text concise (max #{@config[:max_length]} chars)",
@@ -71,12 +94,12 @@ module Uniword
71
94
 
72
95
  # Check for unhelpful generic text
73
96
  unhelpful = %w[image picture photo img graphic icon]
74
- alt_lower = image.alt_text.downcase
97
+ alt_lower = alt_text.downcase
75
98
  if unhelpful.any? do |word|
76
99
  alt_lower == word || alt_lower.start_with?("#{word} of")
77
100
  end
78
101
  violations << create_violation(
79
- message: "Image #{index + 1} has generic alt text: '#{image.alt_text}'",
102
+ message: "Image #{index + 1} has generic alt text: '#{alt_text}'",
80
103
  element: image,
81
104
  severity: :warning,
82
105
  suggestion: "Describe what the image shows, not that it's an image",
@@ -147,6 +147,7 @@ alt_text: nil)
147
147
  inline.doc_properties = WpDrawing::DocProperties.new(
148
148
  id: deterministic_id("inline", path),
149
149
  name: File.basename(path, ".*"),
150
+ descr: alt_text,
150
151
  )
151
152
  inline.graphic = build_graphic(r_id, w, h)
152
153
 
@@ -227,6 +228,7 @@ alt_text: nil)
227
228
  anchor.doc_properties = WpDrawing::DocProperties.new(
228
229
  id: deterministic_id("anchor", path),
229
230
  name: File.basename(path, ".*"),
231
+ descr: alt_text,
230
232
  )
231
233
  anchor.graphic = build_graphic(r_id, w, h)
232
234
 
@@ -98,12 +98,11 @@ module Uniword
98
98
  # @param rule [String, nil] Line rule ('auto', 'exact', 'atLeast')
99
99
  # @return [self]
100
100
  def spacing(before: nil, after: nil, line: nil, rule: nil)
101
- ensure_properties.spacing ||= Properties::Spacing.new
102
- props = @model.properties.spacing
103
- props.before = before if before
104
- props.after = after if after
105
- props.line = line if line
106
- props.line_rule = rule if rule
101
+ written = {
102
+ before: before, after: after, line: line, line_rule: rule
103
+ }.select { |_, value| value }
104
+ sp = ensure_properties.ensure_spacing(*written.keys)
105
+ written.each { |field, value| sp.public_send(:"#{field}=", value) }
107
106
  self
108
107
  end
109
108
 
@@ -63,19 +63,27 @@ module Uniword
63
63
 
64
64
  # Set the lock / content cannot be edited
65
65
  #
66
+ # Passing false writes an explicit <w:temporary w:val="false"/> rather
67
+ # than leaving the flag out, so the intent survives a round trip.
68
+ #
66
69
  # @param value [Boolean] Lock content (default true)
67
70
  # @return [self]
68
- def lock(_value = true)
69
- properties.temporary = Wordprocessingml::StructuredDocumentTag::Temporary.new
71
+ def lock(value = true)
72
+ properties.temporary =
73
+ Wordprocessingml::StructuredDocumentTag::Temporary.new(value: value)
70
74
  self
71
75
  end
72
76
 
73
77
  # Set placeholder text showing the placeholder header
74
78
  #
79
+ # Passing false writes an explicit <w:showingPlcHdr w:val="false"/>,
80
+ # the same way #lock does.
81
+ #
75
82
  # @param value [Boolean] Show placeholder (default true)
76
83
  # @return [self]
77
- def showing_placeholder(_value = true)
78
- properties.showing_placeholder_header = Wordprocessingml::StructuredDocumentTag::ShowingPlaceholderHeader.new
84
+ def showing_placeholder(value = true)
85
+ klass = Wordprocessingml::StructuredDocumentTag::ShowingPlaceholderHeader
86
+ properties.showing_placeholder_header = klass.new(value: value)
79
87
  self
80
88
  end
81
89
 
@@ -102,11 +102,11 @@ module Uniword
102
102
  # @param line [Integer, nil] Line spacing in twips
103
103
  # @return [self]
104
104
  def spacing(before: nil, after: nil, line: nil)
105
- ensure_para_props
106
- @model.pPr.spacing ||= Properties::Spacing.new
107
- @model.pPr.spacing.before = before if before
108
- @model.pPr.spacing.after = after if after
109
- @model.pPr.spacing.line = line if line
105
+ written = {
106
+ before: before, after: after, line: line
107
+ }.select { |_, value| value }
108
+ sp = ensure_para_props.ensure_spacing(*written.keys)
109
+ written.each { |field, value| sp.public_send(:"#{field}=", value) }
110
110
  self
111
111
  end
112
112
 
@@ -35,8 +35,8 @@ module Uniword
35
35
  def width(value, rule: nil)
36
36
  ensure_properties
37
37
  @model.properties.table_width ||= Properties::TableWidth.new
38
- @model.properties.table_width.value = value
39
- @model.properties.table_width.rule = rule if rule
38
+ @model.properties.table_width.w = value
39
+ @model.properties.table_width.type = rule if rule
40
40
  self
41
41
  end
42
42
 
@@ -58,9 +58,9 @@ module Uniword
58
58
  # @return [self]
59
59
  def width(value, rule: nil)
60
60
  ensure_properties
61
- @model.properties.width ||= Properties::CellWidth.new
62
- @model.properties.width.value = value
63
- @model.properties.width.rule = rule if rule
61
+ @model.properties.cell_width ||= Properties::CellWidth.new
62
+ @model.properties.cell_width.w = value
63
+ @model.properties.cell_width.type = rule if rule
64
64
  self
65
65
  end
66
66
 
@@ -45,7 +45,8 @@ module Uniword
45
45
  # @return [self]
46
46
  def header_row(value = true)
47
47
  @model.properties ||= Wordprocessingml::TableRowProperties.new
48
- @model.properties.table_header = value
48
+ @model.properties.tbl_header =
49
+ Wordprocessingml::TableRowProperties.coerce_table_header(value)
49
50
  self
50
51
  end
51
52
  end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module Uniword
7
+ # Comments subcommands for Uniword CLI.
8
+ #
9
+ # Provides Word-style comment management:
10
+ # - list: list comments with anchored text
11
+ # - add: add a comment anchored to a paragraph found by search text
12
+ # - resolve: remove a comment and its anchor markers (Word semantics)
13
+ class CommentsCLI < Thor
14
+ include CLIHelpers
15
+
16
+ desc "list FILE", "List all comments with anchored text"
17
+ long_desc <<~DESC
18
+ Display all comments in a document with ID, author, date, text,
19
+ and the text of the paragraph each comment anchors.
20
+
21
+ Examples:
22
+ $ uniword comments list document.docx
23
+ $ uniword comments list document.docx --json
24
+ DESC
25
+ option :json, desc: "Output as JSON (machine-readable)",
26
+ type: :boolean, default: false
27
+ def list(path)
28
+ doc = load_document(path)
29
+ manager = Review::ReviewManager.new(doc)
30
+ comment_list = manager.list_comments
31
+
32
+ if comment_list.empty?
33
+ say "No comments found.", :yellow
34
+ return
35
+ end
36
+
37
+ anchors = manager.comment_anchor_texts
38
+
39
+ if options[:json]
40
+ puts JSON.pretty_generate(comment_list.map do |comment|
41
+ {
42
+ id: comment.comment_id,
43
+ author: comment.author,
44
+ date: comment.date,
45
+ text: comment.text,
46
+ anchored_text: anchors[comment.comment_id.to_s],
47
+ }
48
+ end)
49
+ return
50
+ end
51
+
52
+ say "Comments (#{comment_list.count}):", :green
53
+ comment_list.each_with_index do |comment, idx|
54
+ say " #{idx + 1}. ID: #{comment.comment_id}"
55
+ say " Author: #{comment.author || '(unknown)'}"
56
+ say " Date: #{comment.date || '(unknown)'}"
57
+ say " Text: #{comment.text}"
58
+ anchored = anchors[comment.comment_id.to_s]
59
+ preview = anchored ? anchored[0..60] : "(unanchored)"
60
+ preview += "..." if anchored.to_s.length > 60
61
+ say " Anchor: #{preview}"
62
+ end
63
+ rescue Uniword::Error => e
64
+ handle_error(e)
65
+ rescue StandardError => e
66
+ handle_error(e)
67
+ end
68
+
69
+ desc "add FILE", "Add a comment anchored to a paragraph"
70
+ long_desc <<~DESC
71
+ Add a comment by an author, anchored to the first paragraph whose
72
+ text contains the --at search phrase. Without --at, the comment is
73
+ anchored to the document's last paragraph (unanchored when the
74
+ document has no paragraphs).
75
+
76
+ Examples:
77
+ $ uniword comments add document.docx --author "Jane" \\
78
+ --text "Please verify" --at "Conclusion"
79
+ $ uniword comments add document.docx --author "Jane" \\
80
+ --text "Looks good" -o reviewed.docx
81
+ DESC
82
+ option :author, desc: "Comment author", type: :string, required: true
83
+ option :text, desc: "Comment text", type: :string, required: true
84
+ option :initials, desc: "Author initials", type: :string
85
+ option :at, desc: "Anchor to the first paragraph containing this " \
86
+ "search text", type: :string
87
+ option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
88
+ type: :string
89
+ def add(path)
90
+ doc = load_document(path)
91
+ paragraph = find_anchor_paragraph(doc, options[:at])
92
+ if options[:at] && paragraph.nil?
93
+ say "No paragraph containing '#{options[:at]}' found.", :red
94
+ exit 1
95
+ end
96
+
97
+ manager = Review::ReviewManager.new(doc)
98
+ comment = manager.add_comment(
99
+ text: options[:text],
100
+ author: options[:author],
101
+ initials: options[:initials],
102
+ )
103
+ manager.anchor_comment(comment.comment_id, paragraph)
104
+
105
+ save_output(doc, path)
106
+ target = options[:output] || path
107
+ say "Added comment #{comment.comment_id} by #{options[:author]} " \
108
+ "to #{target}", :green
109
+ rescue Uniword::Error => e
110
+ handle_error(e)
111
+ rescue StandardError => e
112
+ handle_error(e)
113
+ end
114
+
115
+ desc "resolve FILE COMMENT_ID", "Remove a comment and its anchor markers"
116
+ long_desc <<~DESC
117
+ Resolve a comment with Word semantics: the comment entry is removed
118
+ from word/comments.xml and its commentRangeStart, commentRangeEnd,
119
+ and commentReference markers are removed from the body.
120
+
121
+ Examples:
122
+ $ uniword comments resolve document.docx 1
123
+ $ uniword comments resolve document.docx 1 -o resolved.docx
124
+ DESC
125
+ option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
126
+ type: :string
127
+ def resolve(path, comment_id)
128
+ doc = load_document(path)
129
+ manager = Review::ReviewManager.new(doc)
130
+
131
+ unless manager.resolve_comment!(comment_id)
132
+ say "Comment '#{comment_id}' not found.", :red
133
+ exit 1
134
+ end
135
+
136
+ save_output(doc, path)
137
+ say "Resolved comment '#{comment_id}'.", :green
138
+ rescue Uniword::Error => e
139
+ handle_error(e)
140
+ rescue StandardError => e
141
+ handle_error(e)
142
+ end
143
+
144
+ private
145
+
146
+ # Find the paragraph a new comment anchors to: the first paragraph
147
+ # whose text contains the search phrase, or the document's last
148
+ # paragraph when no phrase is given.
149
+ #
150
+ # @param doc [Uniword::Wordprocessingml::DocumentRoot]
151
+ # @param search_text [String, nil]
152
+ # @return [Wordprocessingml::Paragraph, nil]
153
+ def find_anchor_paragraph(doc, search_text)
154
+ paragraphs = []
155
+ FindReplace::ParagraphWalker.each_paragraph([doc.body].compact) do |p|
156
+ paragraphs << p
157
+ end
158
+ return paragraphs.last if search_text.nil?
159
+
160
+ paragraphs.find { |paragraph| paragraph.text.include?(search_text) }
161
+ end
162
+
163
+ # Save the document to the specified output path or overwrite.
164
+ #
165
+ # @param doc [Uniword::Wordprocessingml::DocumentRoot] The document
166
+ # @param original_path [String] The original file path
167
+ # @return [void]
168
+ def save_output(doc, original_path)
169
+ output_path = options[:output] || original_path
170
+ doc.save(output_path)
171
+ end
172
+ end
173
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require "json"
5
+ require "rainbow"
5
6
 
6
7
  module Uniword
7
8
  # Diff subcommands for Uniword CLI.
@@ -103,6 +104,57 @@ module Uniword
103
104
  handle_error(e, verbose: options[:verbose])
104
105
  end
105
106
 
107
+ desc "redline OLD NEW", "Create a tracked-changes (redline) DOCX"
108
+ long_desc <<~DESC
109
+ Compare two DOCX files and write the base document rewritten with
110
+ tracked changes (w:ins / w:del runs), the way Word's Compare
111
+ does. Paragraphs removed in NEW are wrapped in w:del with a
112
+ deleted paragraph mark, paragraphs added in NEW arrive wrapped in
113
+ w:ins with an inserted paragraph mark, and modified paragraphs
114
+ carry their word-level delta as interleaved w:del / w:ins runs.
115
+
116
+ The revision identity (author, date) is applied to every emitted
117
+ change. Tables pass through unchanged.
118
+
119
+ Examples:
120
+ $ uniword diff redline old.docx new.docx -o redline.docx
121
+ $ uniword diff redline old.docx new.docx --author Alice
122
+ $ uniword diff redline old.docx new.docx --json
123
+ DESC
124
+ option :output, aliases: "-o", desc: "Output DOCX path",
125
+ type: :string
126
+ option :author, desc: "Revision author for emitted changes",
127
+ type: :string, default: "uniword"
128
+ option :date, desc: "Revision date (ISO 8601) for emitted changes",
129
+ type: :string
130
+ option :json, desc: "Output summary as JSON", type: :boolean,
131
+ default: false
132
+ def redline(old_path, new_path)
133
+ validate_file_exists(old_path)
134
+ validate_file_exists(new_path)
135
+
136
+ base = load_document(old_path)
137
+ revised = load_document(new_path)
138
+ result = Uniword::Diff::Redliner.new(
139
+ base, revised,
140
+ author: options[:author], date: options[:date]
141
+ ).redline
142
+
143
+ output = options[:output] || default_redline_path(old_path)
144
+ result.document.save(output)
145
+
146
+ if options[:json]
147
+ puts result.to_json
148
+ else
149
+ puts Rainbow(" Redline written to #{output}").green.bright
150
+ puts " #{result.summary}"
151
+ end
152
+ rescue Uniword::Error => e
153
+ handle_error(e)
154
+ rescue StandardError => e
155
+ handle_error(e)
156
+ end
157
+
106
158
  private
107
159
 
108
160
  # Build options hash for DocumentDiffer from CLI options.
@@ -115,6 +167,14 @@ module Uniword
115
167
  }
116
168
  end
117
169
 
170
+ # Default redline output path: base name of OLD with a .redline
171
+ # suffix, e.g. contract.docx -> contract.redline.docx.
172
+ def default_redline_path(old_path)
173
+ dirname = File.dirname(old_path)
174
+ basename = File.basename(old_path, ".*")
175
+ File.join(dirname, "#{basename}.redline.docx")
176
+ end
177
+
118
178
  def validate_file_exists(path)
119
179
  return if File.exist?(path)
120
180
 
@@ -536,6 +536,78 @@ module Uniword
536
536
  handle_error(e, verbose: options[:verbose])
537
537
  end
538
538
 
539
+ desc "to md|adoc INPUT", "Convert a DOCX to Markdown or AsciiDoc"
540
+ long_desc <<~DESC
541
+ Convert a Word document to a plain-text editing format -- Markdown
542
+ or AsciiDoc -- through the Coradoc CoreModel bridge.
543
+
544
+ Carried: headings h1-h6, paragraphs, bold/italic/underline/
545
+ strikethrough, lists, tables with header rows, links, bookmarks.
546
+ Dropped or degraded features (headers/footers, comments, tracked
547
+ changes, SDTs, math, images, footnotes) are reported; --strict
548
+ fails instead of losing them.
549
+
550
+ Examples:
551
+ $ uniword to md report.docx
552
+ $ uniword to adoc report.docx -o report.adoc
553
+ $ uniword to md report.docx -o - --strict
554
+ DESC
555
+ option :output, aliases: "-o", type: :string,
556
+ desc: "Output path ('-' for stdout; " \
557
+ "default: <input>.md/.adoc)"
558
+ option :strict, type: :boolean, default: false,
559
+ desc: "Fail when fidelity loss would occur"
560
+ option :json, type: :boolean, default: false,
561
+ desc: "Report as JSON (requires an output file)"
562
+ def to(format, input_path)
563
+ validate_plain_text_json!
564
+ result = Cli::PlainTextCommand.convert_to(
565
+ format, input_path, strict: options[:strict]
566
+ )
567
+ output_path = options[:output] || Cli::PlainTextCommand.default_output(
568
+ input_path, Cli::PlainTextCommand.extension_for(format)
569
+ )
570
+ Cli::PlainTextCommand.write_text(result, output_path)
571
+ report_plain_text(result, input_path, output_path)
572
+ rescue Uniword::Error => e
573
+ handle_error(e)
574
+ rescue StandardError => e
575
+ handle_error(e)
576
+ end
577
+
578
+ desc "from md|adoc INPUT", "Convert Markdown or AsciiDoc to a DOCX"
579
+ long_desc <<~DESC
580
+ Convert a Markdown or AsciiDoc file to a Word document through
581
+ the Coradoc CoreModel bridge. Headings become Heading styles,
582
+ lists get numbering, tables keep their header rows, and
583
+ AsciiDoc links become real hyperlinks.
584
+
585
+ Examples:
586
+ $ uniword from md notes.md
587
+ $ uniword from adoc report.adoc -o report.docx
588
+ $ uniword from md notes.md --json
589
+ DESC
590
+ option :output, aliases: "-o", type: :string,
591
+ desc: "Output DOCX path (default: <input>.docx)"
592
+ option :strict, type: :boolean, default: false,
593
+ desc: "Fail when fidelity loss would occur"
594
+ option :json, type: :boolean, default: false,
595
+ desc: "Report as JSON"
596
+ def from(format, input_path)
597
+ result = Cli::PlainTextCommand.convert_from(
598
+ format, input_path, strict: options[:strict]
599
+ )
600
+ output_path = options[:output] || Cli::PlainTextCommand.default_output(
601
+ input_path, ".docx"
602
+ )
603
+ result.document.save(output_path)
604
+ report_plain_text(result, input_path, output_path)
605
+ rescue Uniword::Error => e
606
+ handle_error(e)
607
+ rescue StandardError => e
608
+ handle_error(e)
609
+ end
610
+
539
611
  # Register subcommands
540
612
  desc "theme SUBCOMMAND", "Manage document themes"
541
613
  subcommand "theme", ThemeCLI
@@ -561,6 +633,13 @@ module Uniword
561
633
  desc "review SUBCOMMAND", "Review comments and tracked changes"
562
634
  subcommand "review", ReviewCLI
563
635
 
636
+ desc "comments SUBCOMMAND", "Manage document comments"
637
+ subcommand "comments", CommentsCLI
638
+
639
+ desc "revisions SUBCOMMAND",
640
+ "Tracked changes (alias of review changes/accept/reject)"
641
+ subcommand "revisions", RevisionsCLI
642
+
564
643
  desc "diff SUBCOMMAND", "Compare two DOCX files"
565
644
  subcommand "diff", DiffCLI
566
645
 
@@ -592,6 +671,31 @@ module Uniword
592
671
 
593
672
  private
594
673
 
674
+ # -- plain-text (to/from) helpers -----------------------------------
675
+
676
+ # --json pairs with an output file; stdout text and a JSON report
677
+ # cannot share the stream.
678
+ def validate_plain_text_json!
679
+ return unless options[:json] && options[:output] == "-"
680
+
681
+ say("Error: --json cannot be combined with '-o -'", :red)
682
+ exit 1
683
+ end
684
+
685
+ def report_plain_text(result, input_path, output_path)
686
+ if options[:json]
687
+ require "json"
688
+ puts JSON.pretty_generate(
689
+ result.to_h.merge(input: input_path, output: output_path)
690
+ )
691
+ return
692
+ end
693
+
694
+ say result.summary, result.clean? ? :green : :yellow
695
+ destination = output_path == "-" ? "stdout" : output_path
696
+ say "Converted #{input_path} -> #{destination}", :green
697
+ end
698
+
595
699
  # -- find-replace helpers -------------------------------------------
596
700
 
597
701
  # Normalize --scope arguments. `[:all]` collapses to `:all`; any