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.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +223 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/config/ooxml/schemas/shared_types.yml +5 -4
  5. data/data/schemas/ecma/dc.xsd +118 -0
  6. data/data/schemas/ecma/dcmitype.xsd +50 -0
  7. data/data/schemas/ecma/dcterms.xsd +331 -0
  8. data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
  9. data/data/schemas/ecma/xml.xsd +117 -0
  10. data/lib/uniword/builder/bibliography_builder.rb +1 -1
  11. data/lib/uniword/builder/chart_builder.rb +19 -9
  12. data/lib/uniword/builder/comment_anchorer.rb +221 -0
  13. data/lib/uniword/builder/document_builder.rb +97 -6
  14. data/lib/uniword/builder/image_builder.rb +6 -5
  15. data/lib/uniword/builder/run_utils.rb +2 -0
  16. data/lib/uniword/builder.rb +1 -0
  17. data/lib/uniword/cli/fonts_cli.rb +52 -0
  18. data/lib/uniword/cli/main.rb +110 -11
  19. data/lib/uniword/cli/page_cli.rb +85 -0
  20. data/lib/uniword/cli/styles_cli.rb +204 -0
  21. data/lib/uniword/cli/theme_cli.rb +88 -0
  22. data/lib/uniword/cli/toc_cli.rb +22 -2
  23. data/lib/uniword/comment.rb +20 -15
  24. data/lib/uniword/comment_range.rb +3 -3
  25. data/lib/uniword/comments_part.rb +51 -19
  26. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  27. data/lib/uniword/configuration.rb +93 -1
  28. data/lib/uniword/content_types.rb +19 -39
  29. data/lib/uniword/document_factory.rb +9 -1
  30. data/lib/uniword/document_writer.rb +16 -5
  31. data/lib/uniword/docx/chart_part.rb +34 -0
  32. data/lib/uniword/docx/custom_xml_item.rb +83 -0
  33. data/lib/uniword/docx/header_footer_part.rb +140 -0
  34. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  35. data/lib/uniword/docx/header_footer_view.rb +225 -0
  36. data/lib/uniword/docx/id_allocator.rb +130 -36
  37. data/lib/uniword/docx/package.rb +223 -35
  38. data/lib/uniword/docx/package_defaults.rb +58 -78
  39. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  40. data/lib/uniword/docx/package_serialization.rb +103 -231
  41. data/lib/uniword/docx/part.rb +106 -0
  42. data/lib/uniword/docx/part_collection.rb +123 -0
  43. data/lib/uniword/docx/reconciler/body.rb +59 -68
  44. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  45. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  46. data/lib/uniword/docx/reconciler/helpers.rb +9 -1
  47. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  48. data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
  49. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  50. data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
  51. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  52. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  53. data/lib/uniword/docx/reconciler.rb +36 -14
  54. data/lib/uniword/docx.rb +10 -0
  55. data/lib/uniword/drawingml/blip.rb +0 -1
  56. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  57. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  58. data/lib/uniword/drawingml/theme.rb +12 -6
  59. data/lib/uniword/errors.rb +8 -1
  60. data/lib/uniword/footer.rb +4 -0
  61. data/lib/uniword/header.rb +4 -0
  62. data/lib/uniword/hyperlink.rb +1 -1
  63. data/lib/uniword/image.rb +6 -6
  64. data/lib/uniword/lazy_loader.rb +1 -2
  65. data/lib/uniword/model_attribute_access.rb +20 -4
  66. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  67. data/lib/uniword/ooxml/part_definition.rb +171 -0
  68. data/lib/uniword/ooxml/part_registry.rb +311 -0
  69. data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
  70. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  71. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  72. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  73. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  74. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  75. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  76. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  77. data/lib/uniword/ooxml/types.rb +10 -0
  78. data/lib/uniword/ooxml.rb +2 -0
  79. data/lib/uniword/properties/alignment.rb +8 -3
  80. data/lib/uniword/properties/border.rb +52 -6
  81. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  82. data/lib/uniword/properties/color_value.rb +8 -3
  83. data/lib/uniword/properties/highlight.rb +9 -5
  84. data/lib/uniword/properties/shading.rb +4 -3
  85. data/lib/uniword/properties/tab_stop.rb +6 -1
  86. data/lib/uniword/properties/table_justification.rb +4 -1
  87. data/lib/uniword/properties/underline.rb +4 -2
  88. data/lib/uniword/properties/vertical_align.rb +6 -3
  89. data/lib/uniword/review/review_manager.rb +23 -9
  90. data/lib/uniword/revision.rb +9 -9
  91. data/lib/uniword/schema/model_generator.rb +31 -15
  92. data/lib/uniword/shared_types/hex_color.rb +4 -1
  93. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  94. data/lib/uniword/shared_types/point_measure.rb +4 -1
  95. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  96. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  97. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  98. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  99. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  100. data/lib/uniword/template/variable_resolver.rb +40 -7
  101. data/lib/uniword/themes/theme_transformation.rb +55 -41
  102. data/lib/uniword/toc/toc_generator.rb +2 -0
  103. data/lib/uniword/validation/engine.rb +35 -0
  104. data/lib/uniword/validation/link_checker.rb +1 -1
  105. data/lib/uniword/validation/opc_validator.rb +0 -1
  106. data/lib/uniword/validation/report/layer_result.rb +0 -1
  107. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  108. data/lib/uniword/validation/report/verification_report.rb +0 -2
  109. data/lib/uniword/validation/report.rb +20 -0
  110. data/lib/uniword/validation/rules/base.rb +10 -2
  111. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  112. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  113. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  114. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  115. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  116. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  117. data/lib/uniword/validation/rules/document_context.rb +7 -0
  118. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  119. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  120. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  121. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  122. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  123. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  124. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  125. data/lib/uniword/validation/rules/model_context.rb +32 -0
  126. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  127. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  128. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  129. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  130. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  133. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  134. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  135. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  136. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  137. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  140. data/lib/uniword/validation/rules.rb +61 -23
  141. data/lib/uniword/validation/schema_registry.rb +11 -0
  142. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  143. data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
  144. data/lib/uniword/validation/validators.rb +0 -9
  145. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  146. data/lib/uniword/validation.rb +7 -2
  147. data/lib/uniword/version.rb +1 -1
  148. data/lib/uniword/vml/imagedata.rb +0 -1
  149. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  150. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  151. data/lib/uniword/wordprocessingml/document_root.rb +211 -21
  152. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  153. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  154. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  155. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  156. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  157. data/lib/uniword/wordprocessingml/level.rb +6 -20
  158. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  159. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  160. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  161. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  162. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  163. data/lib/uniword/wordprocessingml/run.rb +3 -0
  164. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  165. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  166. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  167. data/lib/uniword/wordprocessingml/style.rb +2 -1
  168. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  169. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  170. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  171. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  172. data/lib/uniword/wordprocessingml.rb +4 -0
  173. data/lib/uniword.rb +27 -2
  174. metadata +38 -19
  175. data/config/validation_rules.yml +0 -60
  176. data/config/warning_rules.yml +0 -57
  177. data/lib/uniword/validation/document_validator.rb +0 -272
  178. data/lib/uniword/validation/structural_validator.rb +0 -116
  179. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  180. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  181. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  182. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  183. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  184. data/lib/uniword/validators/element_validator.rb +0 -93
  185. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  186. data/lib/uniword/validators/table_validator.rb +0 -134
  187. data/lib/uniword/validators.rb +0 -9
  188. data/lib/uniword/warnings/warning.rb +0 -130
  189. data/lib/uniword/warnings/warning_collector.rb +0 -234
  190. data/lib/uniword/warnings/warning_report.rb +0 -159
  191. 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
- @model.allocator = allocator if allocator
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 and store it in the document's comments collection.
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
- def comment(author:, text: nil, initials: nil, &block)
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
- @model.comments ||= []
497
- @model.comments << comment_obj
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 = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
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) ? document.allocator : nil
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
@@ -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
@@ -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
- if doc.valid?
111
- say "Document structure is valid", :green
112
- else
113
- say "Document has structural issues", :yellow
114
- end
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