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.
- 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 +425 -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/errors.rb +33 -0
- data/lib/uniword/mhtml/word_css.rb +16 -8
- data/lib/uniword/omml_integration.rb +62 -0
- 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/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 +19 -0
- metadata +21 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a100b8d47c3610f0e1443e481b3baf95697bf7da3275c3157b8e148faa01fbab
|
|
4
|
+
data.tar.gz: 88054dad6bf458eccfce74a58350d6afe839515847cbed377e762669dde0bbfb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6fd9d522d692c981deb2cde0aa56e654f19a086ce4e8a359f379bbb102243c90464998ffd6e5917a0a3f246cba8a595b65f937547a1a274f3ad1dca6e6a269d2
|
|
7
|
+
data.tar.gz: ae2d601a492a258b692726330a9f7b7d3e67142571e74377d3ae5c383e51919d2d45c03273dd90aef9af12d3f5f46ddcb84d1cd768f3066e3ee4717b7c2a10d9
|
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
|
-
|
|
22
|
-
|
|
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:
|
|
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 [
|
|
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] &&
|
|
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
|
|
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] &&
|
|
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
|
|
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 =
|
|
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: '#{
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
69
|
-
properties.temporary =
|
|
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(
|
|
78
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
39
|
-
@model.properties.table_width.
|
|
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.
|
|
62
|
-
@model.properties.
|
|
63
|
-
@model.properties.
|
|
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.
|
|
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
|
data/lib/uniword/cli/diff_cli.rb
CHANGED
|
@@ -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
|
|
data/lib/uniword/cli/main.rb
CHANGED
|
@@ -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
|