uniword 1.2.5 → 1.3.0
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 +223 -0
- data/CONTRIBUTING.md +1 -1
- data/config/ooxml/schemas/shared_types.yml +5 -4
- data/data/schemas/ecma/dc.xsd +118 -0
- data/data/schemas/ecma/dcmitype.xsd +50 -0
- data/data/schemas/ecma/dcterms.xsd +331 -0
- data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
- data/data/schemas/ecma/xml.xsd +117 -0
- data/lib/uniword/builder/bibliography_builder.rb +1 -1
- data/lib/uniword/builder/chart_builder.rb +19 -9
- data/lib/uniword/builder/comment_anchorer.rb +221 -0
- data/lib/uniword/builder/document_builder.rb +97 -6
- data/lib/uniword/builder/image_builder.rb +6 -5
- data/lib/uniword/builder/run_utils.rb +2 -0
- data/lib/uniword/builder.rb +1 -0
- data/lib/uniword/cli/fonts_cli.rb +52 -0
- data/lib/uniword/cli/main.rb +110 -11
- data/lib/uniword/cli/page_cli.rb +85 -0
- data/lib/uniword/cli/styles_cli.rb +204 -0
- data/lib/uniword/cli/theme_cli.rb +88 -0
- data/lib/uniword/cli/toc_cli.rb +22 -2
- data/lib/uniword/comment.rb +20 -15
- data/lib/uniword/comment_range.rb +3 -3
- data/lib/uniword/comments_part.rb +51 -19
- data/lib/uniword/configuration/configuration_loader.rb +5 -3
- data/lib/uniword/configuration.rb +93 -1
- data/lib/uniword/content_types.rb +19 -39
- data/lib/uniword/document_factory.rb +9 -1
- data/lib/uniword/document_writer.rb +16 -5
- data/lib/uniword/docx/chart_part.rb +34 -0
- data/lib/uniword/docx/custom_xml_item.rb +83 -0
- data/lib/uniword/docx/header_footer_part.rb +140 -0
- data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
- data/lib/uniword/docx/header_footer_view.rb +225 -0
- data/lib/uniword/docx/id_allocator.rb +130 -36
- data/lib/uniword/docx/package.rb +223 -35
- data/lib/uniword/docx/package_defaults.rb +58 -78
- data/lib/uniword/docx/package_integrity_checker.rb +312 -0
- data/lib/uniword/docx/package_serialization.rb +103 -231
- data/lib/uniword/docx/part.rb +106 -0
- data/lib/uniword/docx/part_collection.rb +123 -0
- data/lib/uniword/docx/reconciler/body.rb +59 -68
- data/lib/uniword/docx/reconciler/fix.rb +46 -0
- data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
- data/lib/uniword/docx/reconciler/helpers.rb +9 -1
- data/lib/uniword/docx/reconciler/notes.rb +17 -8
- data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
- data/lib/uniword/docx/reconciler/parts.rb +36 -13
- data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
- data/lib/uniword/docx/reconciler/tables.rb +78 -16
- data/lib/uniword/docx/reconciler/theme.rb +7 -4
- data/lib/uniword/docx/reconciler.rb +36 -14
- data/lib/uniword/docx.rb +10 -0
- data/lib/uniword/drawingml/blip.rb +0 -1
- data/lib/uniword/drawingml/color_scheme.rb +5 -6
- data/lib/uniword/drawingml/font_scheme.rb +4 -8
- data/lib/uniword/drawingml/theme.rb +12 -6
- data/lib/uniword/errors.rb +8 -1
- data/lib/uniword/footer.rb +4 -0
- data/lib/uniword/header.rb +4 -0
- data/lib/uniword/hyperlink.rb +1 -1
- data/lib/uniword/image.rb +6 -6
- data/lib/uniword/lazy_loader.rb +1 -2
- data/lib/uniword/model_attribute_access.rb +20 -4
- data/lib/uniword/ooxml/dotx_package.rb +42 -2
- data/lib/uniword/ooxml/part_definition.rb +171 -0
- data/lib/uniword/ooxml/part_registry.rb +311 -0
- data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
- data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
- data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
- data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
- data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
- data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
- data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
- data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
- data/lib/uniword/ooxml/types.rb +10 -0
- data/lib/uniword/ooxml.rb +2 -0
- data/lib/uniword/properties/alignment.rb +8 -3
- data/lib/uniword/properties/border.rb +52 -6
- data/lib/uniword/properties/cell_vertical_align.rb +4 -1
- data/lib/uniword/properties/color_value.rb +8 -3
- data/lib/uniword/properties/highlight.rb +9 -5
- data/lib/uniword/properties/shading.rb +4 -3
- data/lib/uniword/properties/tab_stop.rb +6 -1
- data/lib/uniword/properties/table_justification.rb +4 -1
- data/lib/uniword/properties/underline.rb +4 -2
- data/lib/uniword/properties/vertical_align.rb +6 -3
- data/lib/uniword/review/review_manager.rb +23 -9
- data/lib/uniword/revision.rb +9 -9
- data/lib/uniword/schema/model_generator.rb +31 -15
- data/lib/uniword/shared_types/hex_color.rb +4 -1
- data/lib/uniword/shared_types/pixel_measure.rb +4 -1
- data/lib/uniword/shared_types/point_measure.rb +4 -1
- data/lib/uniword/shared_types/text_alignment.rb +6 -1
- data/lib/uniword/shared_types/twips_measure.rb +4 -1
- data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
- data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
- data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
- data/lib/uniword/template/variable_resolver.rb +40 -7
- data/lib/uniword/themes/theme_transformation.rb +55 -41
- data/lib/uniword/toc/toc_generator.rb +2 -0
- data/lib/uniword/validation/engine.rb +35 -0
- data/lib/uniword/validation/link_checker.rb +1 -1
- data/lib/uniword/validation/opc_validator.rb +0 -1
- data/lib/uniword/validation/report/layer_result.rb +0 -1
- data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
- data/lib/uniword/validation/report/verification_report.rb +0 -2
- data/lib/uniword/validation/report.rb +20 -0
- data/lib/uniword/validation/rules/base.rb +10 -2
- data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
- data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
- data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
- data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
- data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
- data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
- data/lib/uniword/validation/rules/document_context.rb +7 -0
- data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
- data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
- data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
- data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
- data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
- data/lib/uniword/validation/rules/images_rule.rb +0 -2
- data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
- data/lib/uniword/validation/rules/model_context.rb +32 -0
- data/lib/uniword/validation/rules/model_rule.rb +34 -0
- data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
- data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
- data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
- data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
- data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
- data/lib/uniword/validation/rules/settings_rule.rb +0 -2
- data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
- data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
- data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
- data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
- data/lib/uniword/validation/rules/tables_rule.rb +0 -2
- data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
- data/lib/uniword/validation/rules/theme_rule.rb +0 -2
- data/lib/uniword/validation/rules.rb +61 -23
- data/lib/uniword/validation/schema_registry.rb +11 -0
- data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
- data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
- data/lib/uniword/validation/validators.rb +0 -9
- data/lib/uniword/validation/verify_orchestrator.rb +0 -7
- data/lib/uniword/validation.rb +7 -2
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/vml/imagedata.rb +0 -1
- data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
- data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
- data/lib/uniword/wordprocessingml/document_root.rb +211 -21
- data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
- data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
- data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
- data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
- data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
- data/lib/uniword/wordprocessingml/level.rb +6 -20
- data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
- data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
- data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
- data/lib/uniword/wordprocessingml/rsids.rb +0 -2
- data/lib/uniword/wordprocessingml/run.rb +3 -0
- data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
- data/lib/uniword/wordprocessingml/settings.rb +8 -34
- data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
- data/lib/uniword/wordprocessingml/style.rb +2 -1
- data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
- data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
- data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
- data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
- data/lib/uniword/wordprocessingml.rb +4 -0
- data/lib/uniword.rb +27 -2
- metadata +38 -19
- data/config/validation_rules.yml +0 -60
- data/config/warning_rules.yml +0 -57
- data/lib/uniword/validation/document_validator.rb +0 -272
- data/lib/uniword/validation/structural_validator.rb +0 -116
- data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
- data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
- data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
- data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
- data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
- data/lib/uniword/validators/element_validator.rb +0 -93
- data/lib/uniword/validators/paragraph_validator.rb +0 -116
- data/lib/uniword/validators/table_validator.rb +0 -134
- data/lib/uniword/validators.rb +0 -9
- data/lib/uniword/warnings/warning.rb +0 -130
- data/lib/uniword/warnings/warning_collector.rb +0 -234
- data/lib/uniword/warnings/warning_report.rb +0 -159
- data/lib/uniword/warnings.rb +0 -9
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Builder
|
|
5
|
+
# Anchors a comment to a body paragraph.
|
|
6
|
+
#
|
|
7
|
+
# Word anchors a comment with three pieces of markup around the
|
|
8
|
+
# commented content:
|
|
9
|
+
#
|
|
10
|
+
# <w:commentRangeStart w:id="0"/>
|
|
11
|
+
# ... commented runs ...
|
|
12
|
+
# <w:commentRangeEnd w:id="0"/>
|
|
13
|
+
# <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
|
|
14
|
+
# <w:commentReference w:id="0"/></w:r>
|
|
15
|
+
#
|
|
16
|
+
# Paragraph uses mixed-content serialization, so the anchor elements
|
|
17
|
+
# must also be recorded in the paragraph's element_order — fresh
|
|
18
|
+
# paragraphs get a complete order built from their current content,
|
|
19
|
+
# parsed paragraphs get the new entries inserted in place (a second
|
|
20
|
+
# anchor on the same paragraph nests inside the first, matching
|
|
21
|
+
# Word's canonical output).
|
|
22
|
+
#
|
|
23
|
+
# @example Anchor a comment on the last paragraph of a document
|
|
24
|
+
# CommentAnchorer.anchor(document.body.paragraphs.last, "1")
|
|
25
|
+
class CommentAnchorer
|
|
26
|
+
# Character style Word applies to comment reference runs.
|
|
27
|
+
REFERENCE_STYLE = "CommentReference"
|
|
28
|
+
|
|
29
|
+
# Paragraph content element names in mapping declaration order,
|
|
30
|
+
# excluding pPr and the comment anchor elements (handled
|
|
31
|
+
# separately). Mirrors Paragraph's `xml do` block; used to rebuild
|
|
32
|
+
# element_order for programmatically anchored paragraphs.
|
|
33
|
+
CONTENT_ELEMENTS = %w[
|
|
34
|
+
hyperlink bookmarkStart bookmarkEnd fldChar instrText
|
|
35
|
+
commentReference AlternateContent sdt oMathPara oMath proofErr
|
|
36
|
+
fldSimple
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# Element name → collection reader, used to size element_order
|
|
40
|
+
# entries without dynamic dispatch.
|
|
41
|
+
COLLECTION_READERS = {
|
|
42
|
+
"hyperlink" => :hyperlinks.to_proc,
|
|
43
|
+
"bookmarkStart" => :bookmark_starts.to_proc,
|
|
44
|
+
"bookmarkEnd" => :bookmark_ends.to_proc,
|
|
45
|
+
"fldChar" => :field_chars.to_proc,
|
|
46
|
+
"instrText" => :instr_text.to_proc,
|
|
47
|
+
"commentReference" => :comment_references.to_proc,
|
|
48
|
+
"AlternateContent" => :alternate_content.to_proc,
|
|
49
|
+
"sdt" => :sdts.to_proc,
|
|
50
|
+
"oMathPara" => :o_math_paras.to_proc,
|
|
51
|
+
"oMath" => :o_maths.to_proc,
|
|
52
|
+
"proofErr" => :proof_errors.to_proc,
|
|
53
|
+
"fldSimple" => :simple_fields.to_proc,
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# Anchor a comment around the paragraph's current content.
|
|
58
|
+
# No-op when the paragraph is nil (comment stays unanchored but
|
|
59
|
+
# remains valid in word/comments.xml).
|
|
60
|
+
#
|
|
61
|
+
# @param paragraph [Wordprocessingml::Paragraph, nil] anchor target
|
|
62
|
+
# @param comment_id [String, Integer] ID of the comment entry
|
|
63
|
+
# @return [void]
|
|
64
|
+
def anchor(paragraph, comment_id)
|
|
65
|
+
return unless paragraph
|
|
66
|
+
|
|
67
|
+
id = comment_id.to_s
|
|
68
|
+
paragraph.comment_range_starts << range_start(id)
|
|
69
|
+
paragraph.comment_range_ends << range_end(id)
|
|
70
|
+
paragraph.runs << reference_run(id)
|
|
71
|
+
sync_element_order(paragraph)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def range_start(id)
|
|
77
|
+
Wordprocessingml::CommentRangeStart.new(id: id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def range_end(id)
|
|
81
|
+
Wordprocessingml::CommentRangeEnd.new(id: id)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The run carrying w:commentReference, styled with Word's
|
|
85
|
+
# CommentReference character style.
|
|
86
|
+
def reference_run(id)
|
|
87
|
+
run = Wordprocessingml::Run.new
|
|
88
|
+
run.properties = Wordprocessingml::RunProperties.new
|
|
89
|
+
run.properties.style =
|
|
90
|
+
Properties::RunStyleReference.new(value: REFERENCE_STYLE)
|
|
91
|
+
run.comment_reference = Wordprocessingml::CommentReference.new(id: id)
|
|
92
|
+
run
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Record the anchor elements in element_order so mixed-content
|
|
96
|
+
# serialization emits them in anchor position. Parsed paragraphs
|
|
97
|
+
# with a consistent order get in-place insertion; anything else
|
|
98
|
+
# gets a full rebuild from current content.
|
|
99
|
+
def sync_element_order(paragraph)
|
|
100
|
+
order = paragraph.element_order
|
|
101
|
+
paragraph.element_order =
|
|
102
|
+
if order.nil? || order.empty? || inconsistent?(order, paragraph)
|
|
103
|
+
fresh_element_order(paragraph)
|
|
104
|
+
else
|
|
105
|
+
inserted_element_order(order, paragraph)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# element_order tracks every collection except the just-added
|
|
110
|
+
# anchor elements; any mismatch means it is stale and a rebuild
|
|
111
|
+
# is safer than insertion.
|
|
112
|
+
def inconsistent?(order, paragraph)
|
|
113
|
+
count(order, "r") != paragraph.runs.size - 1 ||
|
|
114
|
+
count(order, "commentRangeStart") !=
|
|
115
|
+
paragraph.comment_range_starts.size - 1 ||
|
|
116
|
+
count(order, "commentRangeEnd") !=
|
|
117
|
+
paragraph.comment_range_ends.size - 1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def count(order, name)
|
|
121
|
+
order.count { |e| e.name == name }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Insert the new anchor entries into an existing order: the start
|
|
125
|
+
# marker after the last existing start marker (or pPr), the end
|
|
126
|
+
# marker before the first reference run, and the new reference
|
|
127
|
+
# run's entry last.
|
|
128
|
+
def inserted_element_order(order, paragraph)
|
|
129
|
+
entries = order.dup
|
|
130
|
+
insert_start_entry(entries)
|
|
131
|
+
insert_end_entry(entries, paragraph)
|
|
132
|
+
entries << xml_element("r")
|
|
133
|
+
entries
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def insert_start_entry(entries)
|
|
137
|
+
pos = entries.rindex { |e| e.name == "commentRangeStart" }
|
|
138
|
+
pos ||= entries.index { |e| e.name == "pPr" }
|
|
139
|
+
entries.insert(pos ? pos + 1 : 0, xml_element("commentRangeStart"))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def insert_end_entry(entries, paragraph)
|
|
143
|
+
ref_index = paragraph.runs.index(&:comment_reference)
|
|
144
|
+
r_positions = entries.each_index.select do |i|
|
|
145
|
+
entries[i].name == "r"
|
|
146
|
+
end
|
|
147
|
+
if ref_index && r_positions[ref_index]
|
|
148
|
+
entries.insert(r_positions[ref_index],
|
|
149
|
+
xml_element("commentRangeEnd"))
|
|
150
|
+
else
|
|
151
|
+
entries << xml_element("commentRangeEnd")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Build a complete element_order from the paragraph's current
|
|
156
|
+
# content: pPr, start markers, content runs and other content,
|
|
157
|
+
# end markers, then the reference runs — Word's canonical
|
|
158
|
+
# anchor layout.
|
|
159
|
+
def fresh_element_order(paragraph)
|
|
160
|
+
prefix_entries(paragraph) + content_entries(paragraph) +
|
|
161
|
+
suffix_entries(paragraph)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# pPr plus all commentRangeStart entries.
|
|
165
|
+
def prefix_entries(paragraph)
|
|
166
|
+
entries = []
|
|
167
|
+
entries << xml_element("pPr") if paragraph.properties
|
|
168
|
+
paragraph.comment_range_starts.size.times do
|
|
169
|
+
entries << xml_element("commentRangeStart")
|
|
170
|
+
end
|
|
171
|
+
entries
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# All commentRangeEnd entries plus the reference runs' entries.
|
|
175
|
+
def suffix_entries(paragraph)
|
|
176
|
+
entries = []
|
|
177
|
+
paragraph.comment_range_ends.size.times do
|
|
178
|
+
entries << xml_element("commentRangeEnd")
|
|
179
|
+
end
|
|
180
|
+
reference_runs(paragraph).size.times { entries << xml_element("r") }
|
|
181
|
+
entries
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Entries for all non-anchor content in mapping declaration
|
|
185
|
+
# order, excluding reference runs (appended after end markers).
|
|
186
|
+
def content_entries(paragraph)
|
|
187
|
+
entries = []
|
|
188
|
+
content_run_count(paragraph).times { entries << xml_element("r") }
|
|
189
|
+
CONTENT_ELEMENTS.each do |name|
|
|
190
|
+
count = element_count(paragraph, name)
|
|
191
|
+
count.times { entries << xml_element(name) }
|
|
192
|
+
end
|
|
193
|
+
entries
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def content_run_count(paragraph)
|
|
197
|
+
paragraph.runs.size - reference_runs(paragraph).size
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def reference_runs(paragraph)
|
|
201
|
+
paragraph.runs.select(&:comment_reference)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Collection size for a named content element, derived from the
|
|
205
|
+
# paragraph's mapped collections (single elements count as 1).
|
|
206
|
+
def element_count(paragraph, name)
|
|
207
|
+
reader = COLLECTION_READERS[name]
|
|
208
|
+
collection = reader&.call(paragraph)
|
|
209
|
+
return 0 if collection.nil?
|
|
210
|
+
return collection.size if collection.is_a?(Array)
|
|
211
|
+
|
|
212
|
+
1
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def xml_element(name)
|
|
216
|
+
Lutaml::Xml::Element.new("Element", name)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -35,10 +35,18 @@ module Uniword
|
|
|
35
35
|
Wordprocessingml::DocumentRoot
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Every DocumentBuilder carries an IdAllocator — the single ID
|
|
39
|
+
# authority — so build-time allocations (images, hyperlinks,
|
|
40
|
+
# paragraph ids) and the save path draw from one registry.
|
|
41
|
+
# Reuses an explicit allocator or one already on the model;
|
|
42
|
+
# otherwise populates one seeded from the model's current
|
|
43
|
+
# relationships (loaded documents keep their rIds; fresh models
|
|
44
|
+
# start empty).
|
|
38
45
|
def initialize(model = nil, allocator: nil)
|
|
39
46
|
super(model)
|
|
40
|
-
@allocator = allocator
|
|
41
|
-
|
|
47
|
+
@allocator = allocator || @model.allocator ||
|
|
48
|
+
Docx::IdAllocator.populate_from_package(@model)
|
|
49
|
+
@model.allocator = @allocator
|
|
42
50
|
@footnote_builder = FootnoteBuilder.new(self, allocator: @allocator)
|
|
43
51
|
end
|
|
44
52
|
|
|
@@ -216,6 +224,10 @@ module Uniword
|
|
|
216
224
|
sec.type = type
|
|
217
225
|
block.call(sec) if block_given?
|
|
218
226
|
@model.body.section_properties ||= sec.build
|
|
227
|
+
# Register section-level headers/footers so their sectPr
|
|
228
|
+
# references resolve to real parts at save time.
|
|
229
|
+
sec.header_models.each { |t, hf| (@model.headers ||= {})[t] = hf }
|
|
230
|
+
sec.footer_models.each { |t, hf| (@model.footers ||= {})[t] = hf }
|
|
219
231
|
sec
|
|
220
232
|
end
|
|
221
233
|
|
|
@@ -472,14 +484,35 @@ module Uniword
|
|
|
472
484
|
self
|
|
473
485
|
end
|
|
474
486
|
|
|
475
|
-
# Create a comment
|
|
487
|
+
# Create a comment, register it in the document's comments
|
|
488
|
+
# collection (serialized to word/comments.xml on save), and anchor
|
|
489
|
+
# it around the most recently added body paragraph with
|
|
490
|
+
# commentRangeStart/commentRangeEnd markers and a CommentReference
|
|
491
|
+
# run. With no preceding paragraph the comment stays unanchored.
|
|
476
492
|
#
|
|
477
493
|
# @param author [String] Comment author name
|
|
478
494
|
# @param text [String, nil] Comment text
|
|
479
495
|
# @param initials [String, nil] Author initials
|
|
480
496
|
# @yield [CommentBuilder] Builder for rich comment content
|
|
481
497
|
# @return [Comment] The created Comment model
|
|
482
|
-
|
|
498
|
+
# Create a comment and store it in the document's comments
|
|
499
|
+
# collection.
|
|
500
|
+
#
|
|
501
|
+
# Without `on:`, the comment is anchored around the yielded
|
|
502
|
+
# paragraph (or the document's last paragraph when no block is
|
|
503
|
+
# given). With `on:`, the comment is anchored around an existing
|
|
504
|
+
# paragraph — anywhere in the document, including table cells.
|
|
505
|
+
#
|
|
506
|
+
# @param author [String] Comment author name
|
|
507
|
+
# @param text [String, nil] Comment text
|
|
508
|
+
# @param initials [String, nil] Author initials
|
|
509
|
+
# @param on [Wordprocessingml::Paragraph, Integer, nil] Anchor
|
|
510
|
+
# target: a paragraph object, or an index into body paragraphs
|
|
511
|
+
# @yield [CommentBuilder] Builder for rich comment content
|
|
512
|
+
# @return [Comment] The created Comment model
|
|
513
|
+
# @raise [ArgumentError] when `on` is neither a Paragraph nor an
|
|
514
|
+
# index of an existing body paragraph
|
|
515
|
+
def comment(author:, text: nil, initials: nil, on: nil, &block)
|
|
483
516
|
comment_id = @allocator ? @allocator.alloc_comment_id : begin
|
|
484
517
|
@comment_counter ||= 0
|
|
485
518
|
@comment_counter += 1
|
|
@@ -493,8 +526,10 @@ module Uniword
|
|
|
493
526
|
cb << text if text
|
|
494
527
|
block.call(cb) if block_given?
|
|
495
528
|
comment_obj = cb.build
|
|
496
|
-
|
|
497
|
-
|
|
529
|
+
comments_part.add_comment(comment_obj)
|
|
530
|
+
CommentAnchorer.anchor(comment_anchor_target(on),
|
|
531
|
+
comment_obj.comment_id)
|
|
532
|
+
ensure_comment_reference_style
|
|
498
533
|
comment_obj
|
|
499
534
|
end
|
|
500
535
|
|
|
@@ -597,6 +632,62 @@ module Uniword
|
|
|
597
632
|
def save(path)
|
|
598
633
|
@model.to_file(path)
|
|
599
634
|
end
|
|
635
|
+
|
|
636
|
+
private
|
|
637
|
+
|
|
638
|
+
# The document's comments collection, migrating legacy Array
|
|
639
|
+
# storage (assigned by earlier builder versions) into a
|
|
640
|
+
# CommentsPart so comments serialize to word/comments.xml.
|
|
641
|
+
#
|
|
642
|
+
# @return [Uniword::CommentsPart] The comments collection
|
|
643
|
+
def comments_part
|
|
644
|
+
case (existing = @model.comments)
|
|
645
|
+
when Uniword::CommentsPart then existing
|
|
646
|
+
when Array
|
|
647
|
+
part = Uniword::CommentsPart.new
|
|
648
|
+
existing.each { |c| part.add_comment(c) }
|
|
649
|
+
@model.comments = part
|
|
650
|
+
else
|
|
651
|
+
@model.comments = Uniword::CommentsPart.new
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Resolve the paragraph a comment anchors to: the explicit `on`
|
|
656
|
+
# target when given, else the document's last body paragraph.
|
|
657
|
+
def comment_anchor_target(on)
|
|
658
|
+
case on
|
|
659
|
+
when nil then @model.body&.paragraphs&.last
|
|
660
|
+
when Wordprocessingml::Paragraph then on
|
|
661
|
+
when Integer
|
|
662
|
+
@model.body&.paragraphs&.[](on) ||
|
|
663
|
+
raise(ArgumentError, "no body paragraph at index #{on}")
|
|
664
|
+
else
|
|
665
|
+
raise ArgumentError,
|
|
666
|
+
"comment target must be a Paragraph or paragraph index, " \
|
|
667
|
+
"got #{on.class}"
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Register Word's built-in CommentReference character style when
|
|
672
|
+
# absent, so the anchor run's rStyle resolves against styles.xml
|
|
673
|
+
# (the reconciler strips dangling style references).
|
|
674
|
+
#
|
|
675
|
+
# @return [void]
|
|
676
|
+
def ensure_comment_reference_style
|
|
677
|
+
styles = @model.styles_configuration
|
|
678
|
+
return if styles.style_by_id(CommentAnchorer::REFERENCE_STYLE)
|
|
679
|
+
|
|
680
|
+
styles.add_style(
|
|
681
|
+
Wordprocessingml::Style.new(
|
|
682
|
+
type: "character",
|
|
683
|
+
styleId: CommentAnchorer::REFERENCE_STYLE,
|
|
684
|
+
name: Wordprocessingml::StyleName.new(val: "annotation reference"),
|
|
685
|
+
uiPriority: Wordprocessingml::UiPriority.new(val: 99),
|
|
686
|
+
semiHidden: Wordprocessingml::SemiHidden.new,
|
|
687
|
+
unhideWhenUsed: Wordprocessingml::UnhideWhenUsed.new,
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
end
|
|
600
691
|
end
|
|
601
692
|
end
|
|
602
693
|
end
|
|
@@ -27,7 +27,7 @@ module Uniword
|
|
|
27
27
|
# Picture namespace URI for GraphicData
|
|
28
28
|
PIC_URI = "http://schemas.openxmlformats.org/drawingml/2006/picture"
|
|
29
29
|
|
|
30
|
-
IMAGE_REL_TYPE =
|
|
30
|
+
IMAGE_REL_TYPE = Ooxml::PartRegistry.find_by_key(:image).rel_type
|
|
31
31
|
|
|
32
32
|
# Register an image part on the document for DOCX packaging.
|
|
33
33
|
#
|
|
@@ -36,14 +36,15 @@ module Uniword
|
|
|
36
36
|
# @return [String] Relationship ID (e.g., 'rId5')
|
|
37
37
|
def self.register_image(document, path)
|
|
38
38
|
root = document.is_a?(Uniword::Builder::DocumentBuilder) ? document.model : document
|
|
39
|
-
alloc = document.is_a?(Uniword::Builder::DocumentBuilder)
|
|
39
|
+
alloc = if document.is_a?(Uniword::Builder::DocumentBuilder)
|
|
40
|
+
document.allocator
|
|
41
|
+
elsif root
|
|
42
|
+
root.allocator ||= Docx::IdAllocator.new
|
|
43
|
+
end
|
|
40
44
|
|
|
41
45
|
target = "media/#{File.basename(path)}"
|
|
42
46
|
r_id = if alloc
|
|
43
47
|
alloc.alloc_rid(target: target, type: IMAGE_REL_TYPE)
|
|
44
|
-
elsif root
|
|
45
|
-
root.image_parts ||= {}
|
|
46
|
-
"rId#{root.image_parts.size + 1}"
|
|
47
48
|
else
|
|
48
49
|
"rId#{deterministic_id('img_rid', path)}"
|
|
49
50
|
end
|
|
@@ -22,6 +22,7 @@ module Uniword
|
|
|
22
22
|
return false if run.alternate_content
|
|
23
23
|
return false if run.footnote_reference
|
|
24
24
|
return false if run.endnote_reference
|
|
25
|
+
return false if run.comment_reference
|
|
25
26
|
return false if run.field_char
|
|
26
27
|
return false if run.instr_text
|
|
27
28
|
return false if run.position_tab
|
|
@@ -45,6 +46,7 @@ module Uniword
|
|
|
45
46
|
return false if run.alternate_content
|
|
46
47
|
return false if run.footnote_reference
|
|
47
48
|
return false if run.endnote_reference
|
|
49
|
+
return false if run.comment_reference
|
|
48
50
|
return false if run.field_char
|
|
49
51
|
return false if run.instr_text
|
|
50
52
|
return false if run.sym
|
data/lib/uniword/builder.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Uniword
|
|
|
31
31
|
autoload :NumberingBuilder, "uniword/builder/numbering_builder"
|
|
32
32
|
autoload :ImageBuilder, "uniword/builder/image_builder"
|
|
33
33
|
autoload :CommentBuilder, "uniword/builder/comment_builder"
|
|
34
|
+
autoload :CommentAnchorer, "uniword/builder/comment_anchorer"
|
|
34
35
|
autoload :TocBuilder, "uniword/builder/toc_builder"
|
|
35
36
|
autoload :ListBuilder, "uniword/builder/list_builder"
|
|
36
37
|
autoload :ThemeBuilder, "uniword/builder/theme_builder"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
# Fonts subcommands for Uniword CLI.
|
|
7
|
+
#
|
|
8
|
+
# Rewrites font families inside document content — the CLI equivalent
|
|
9
|
+
# of Word's Home → Replace → Replace Fonts, across styles, defaults,
|
|
10
|
+
# body, headers/footers, notes, comments, and numbering in one pass.
|
|
11
|
+
class FontsCLI < Thor
|
|
12
|
+
include CLIHelpers
|
|
13
|
+
|
|
14
|
+
desc "replace INPUT OUTPUT", "Replace a font family throughout a document"
|
|
15
|
+
long_desc <<~DESC
|
|
16
|
+
Replace one font family with another across styles and defaults,
|
|
17
|
+
body content, headers/footers, notes, comments, and numbering —
|
|
18
|
+
Word's Replace Fonts dialog as a one-shot CLI command. Theme font
|
|
19
|
+
references are untouched; use 'uniword theme fonts' for those.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
$ uniword fonts replace input.docx output.docx --from Calibri --to Carlito
|
|
23
|
+
DESC
|
|
24
|
+
option :from, type: :string, required: true,
|
|
25
|
+
desc: "Font family to replace (exact match)"
|
|
26
|
+
option :to, type: :string, required: true,
|
|
27
|
+
desc: "Replacement font family"
|
|
28
|
+
option :verbose, aliases: "-v", desc: "Verbose output", type: :boolean,
|
|
29
|
+
default: false
|
|
30
|
+
def replace(input_path, output_path)
|
|
31
|
+
say "Loading document #{input_path}...", :green if options[:verbose]
|
|
32
|
+
|
|
33
|
+
count = replace_in_file(input_path, output_path)
|
|
34
|
+
say "Replaced #{count} font reference(s) " \
|
|
35
|
+
"('#{options[:from]}' → '#{options[:to]}') in #{output_path}",
|
|
36
|
+
:green
|
|
37
|
+
rescue Uniword::Error => e
|
|
38
|
+
handle_error(e)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
handle_error(e, verbose: options[:verbose])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def replace_in_file(input_path, output_path)
|
|
46
|
+
doc = load_document(input_path)
|
|
47
|
+
count = doc.replace_font(from: options[:from], to: options[:to])
|
|
48
|
+
doc.save(output_path)
|
|
49
|
+
count
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/uniword/cli/main.rb
CHANGED
|
@@ -91,6 +91,35 @@ module Uniword
|
|
|
91
91
|
handle_error(e)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
desc "repair INPUT OUTPUT", "Repair a document and report the fixes"
|
|
95
|
+
long_desc <<~DESC
|
|
96
|
+
Load a document, run the save-time Reconciler over it, and write a
|
|
97
|
+
repaired copy: dangling references are stripped, missing required
|
|
98
|
+
parts are rebuilt, relationship issues are normalized, and the
|
|
99
|
+
result is verified by the write-time integrity gate. Every repair
|
|
100
|
+
is reported (code, part, message).
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
$ uniword repair broken.docx fixed.docx
|
|
104
|
+
$ uniword repair broken.docx fixed.docx --verbose
|
|
105
|
+
DESC
|
|
106
|
+
option :verbose, aliases: "-v", desc: "Show every applied fix",
|
|
107
|
+
type: :boolean, default: false
|
|
108
|
+
def repair(input_path, output_path)
|
|
109
|
+
say "Loading #{input_path}...", :green
|
|
110
|
+
|
|
111
|
+
fixes = repair_file(input_path, output_path)
|
|
112
|
+
report_repairs(fixes)
|
|
113
|
+
say "Saved to #{output_path}", :green
|
|
114
|
+
rescue Uniword::ValidationError => e
|
|
115
|
+
say "Repair failed — the document has issues the Reconciler " \
|
|
116
|
+
"cannot fix:", :red
|
|
117
|
+
e.issues.each { |issue| say " #{issue.code}: #{issue.message}", :red }
|
|
118
|
+
exit 1
|
|
119
|
+
rescue Uniword::Error, StandardError => e
|
|
120
|
+
handle_error(e)
|
|
121
|
+
end
|
|
122
|
+
|
|
94
123
|
desc "validate FILE", "Validate document structure"
|
|
95
124
|
long_desc <<~DESC
|
|
96
125
|
Validate a document's structure and check for common issues.
|
|
@@ -107,20 +136,15 @@ module Uniword
|
|
|
107
136
|
doc = load_document(path)
|
|
108
137
|
say "File format is valid", :green
|
|
109
138
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if doc.paragraphs.any? || doc.tables.any?
|
|
117
|
-
say "Document contains content", :green
|
|
118
|
-
else
|
|
119
|
-
say "Document appears to be empty", :yellow
|
|
120
|
-
end
|
|
139
|
+
issues = Uniword::Validation::Engine.run(
|
|
140
|
+
Uniword::Validation::Rules::ModelContext.new(doc),
|
|
141
|
+
)
|
|
142
|
+
report_validation_issues(issues)
|
|
143
|
+
report_content_presence(doc)
|
|
121
144
|
|
|
122
145
|
display_detailed_validation(doc) if options[:verbose]
|
|
123
146
|
say "\nValidation complete!", :green
|
|
147
|
+
exit 1 if issues.any?(&:error?)
|
|
124
148
|
rescue Uniword::Error => e
|
|
125
149
|
handle_error(e)
|
|
126
150
|
rescue StandardError => e
|
|
@@ -363,6 +387,15 @@ module Uniword
|
|
|
363
387
|
desc "theme SUBCOMMAND", "Manage document themes"
|
|
364
388
|
subcommand "theme", ThemeCLI
|
|
365
389
|
|
|
390
|
+
desc "fonts SUBCOMMAND", "Replace font families in documents"
|
|
391
|
+
subcommand "fonts", FontsCLI
|
|
392
|
+
|
|
393
|
+
desc "page SUBCOMMAND", "Page setup (size, orientation, margins)"
|
|
394
|
+
subcommand "page", PageCLI
|
|
395
|
+
|
|
396
|
+
desc "styles SUBCOMMAND", "Inspect document styles"
|
|
397
|
+
subcommand "styles", StylesCLI
|
|
398
|
+
|
|
366
399
|
desc "styleset SUBCOMMAND", "Manage document StyleSets"
|
|
367
400
|
subcommand "styleset", StyleSetCLI
|
|
368
401
|
|
|
@@ -399,8 +432,50 @@ module Uniword
|
|
|
399
432
|
desc "protect SUBCOMMAND", "Manage document protection"
|
|
400
433
|
subcommand "protect", ProtectCLI
|
|
401
434
|
|
|
435
|
+
# Fix codes that are routine normalization, applied on every save
|
|
436
|
+
# regardless of document health (package structure, properties,
|
|
437
|
+
# default styles) — not evidence of a broken document.
|
|
438
|
+
BASELINE_FIX_CODES = %w[R1 R6 R7 R8 R14 R24].freeze
|
|
439
|
+
|
|
402
440
|
private
|
|
403
441
|
|
|
442
|
+
# Report substantive repairs; routine normalization is summarized
|
|
443
|
+
# separately so a healthy document does not look "repaired".
|
|
444
|
+
def report_repairs(fixes)
|
|
445
|
+
substantive = substantive_fixes(fixes)
|
|
446
|
+
baseline = fixes.size - substantive.size
|
|
447
|
+
|
|
448
|
+
return report_consistent(baseline) if substantive.empty?
|
|
449
|
+
|
|
450
|
+
say "Applied #{substantive.size} repair(s):", :green
|
|
451
|
+
summarize_fixes(substantive)
|
|
452
|
+
say "(plus #{baseline} routine normalization(s))", :cyan
|
|
453
|
+
substantive.each { |fix| say " #{fix}" } if options[:verbose]
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def substantive_fixes(fixes)
|
|
457
|
+
fixes.reject { |fix| BASELINE_FIX_CODES.include?(fix.code) }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def report_consistent(baseline)
|
|
461
|
+
say "No repairs needed — document consistent " \
|
|
462
|
+
"(#{baseline} routine normalization(s)).", :green
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Load, reconcile, and save; return the reconciler's fix report.
|
|
466
|
+
def repair_file(input_path, output_path)
|
|
467
|
+
package = Docx::Package.from_file(input_path)
|
|
468
|
+
package.to_file(output_path)
|
|
469
|
+
package.applied_fixes
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Print a one-line count per fix code (full list with --verbose).
|
|
473
|
+
def summarize_fixes(fixes)
|
|
474
|
+
fixes.group_by(&:code).sort.each do |code, group|
|
|
475
|
+
say " #{code} ×#{group.size}: #{group.first.message}"
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
404
479
|
def display_verbose_info(doc)
|
|
405
480
|
say "\nDetailed Information:", :cyan
|
|
406
481
|
|
|
@@ -421,6 +496,30 @@ module Uniword
|
|
|
421
496
|
end
|
|
422
497
|
end
|
|
423
498
|
|
|
499
|
+
# Print validation issues grouped by severity.
|
|
500
|
+
def report_validation_issues(issues)
|
|
501
|
+
errors = issues.select(&:error?)
|
|
502
|
+
if errors.empty?
|
|
503
|
+
say "Document structure is valid", :green
|
|
504
|
+
else
|
|
505
|
+
say "Document has structural errors:", :red
|
|
506
|
+
errors.each { |issue| say " [#{issue.code}] #{issue.message}", :red }
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
issues.select(&:warning?).each do |issue|
|
|
510
|
+
say " [#{issue.code}] #{issue.message}", :yellow
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Print whether the document contains any content.
|
|
515
|
+
def report_content_presence(doc)
|
|
516
|
+
if doc.paragraphs.any? || doc.tables.any?
|
|
517
|
+
say "Document contains content", :green
|
|
518
|
+
else
|
|
519
|
+
say "Document appears to be empty", :yellow
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
424
523
|
def display_detailed_validation(doc)
|
|
425
524
|
say "\nDetailed Validation:", :cyan
|
|
426
525
|
|