uniword 1.2.5 → 1.4.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +313 -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 +22 -14
  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 +11 -10
  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 +12 -18
  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 +91 -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/image_part.rb +83 -0
  38. data/lib/uniword/docx/package.rb +141 -333
  39. data/lib/uniword/docx/package_defaults.rb +83 -99
  40. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  41. data/lib/uniword/docx/package_serialization.rb +144 -233
  42. data/lib/uniword/docx/part.rb +125 -0
  43. data/lib/uniword/docx/part_collection.rb +131 -0
  44. data/lib/uniword/docx/part_loader/chart_loader.rb +50 -0
  45. data/lib/uniword/docx/part_loader/custom_xml_loader.rb +53 -0
  46. data/lib/uniword/docx/part_loader/embedding_loader.rb +26 -0
  47. data/lib/uniword/docx/part_loader/header_footer_loader.rb +101 -0
  48. data/lib/uniword/docx/part_loader/image_loader.rb +105 -0
  49. data/lib/uniword/docx/part_loader/load_context.rb +85 -0
  50. data/lib/uniword/docx/part_loader/raw_part_loader.rb +183 -0
  51. data/lib/uniword/docx/part_loader/theme_media_loader.rb +39 -0
  52. data/lib/uniword/docx/part_loader/xml_model_loader.rb +56 -0
  53. data/lib/uniword/docx/part_loader.rb +92 -0
  54. data/lib/uniword/docx/raw_part.rb +75 -0
  55. data/lib/uniword/docx/reconciler/body.rb +59 -68
  56. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  57. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  58. data/lib/uniword/docx/reconciler/helpers.rb +18 -31
  59. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  60. data/lib/uniword/docx/reconciler/package_structure.rb +168 -221
  61. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  62. data/lib/uniword/docx/reconciler/referential_integrity.rb +222 -123
  63. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  64. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  65. data/lib/uniword/docx/reconciler.rb +36 -14
  66. data/lib/uniword/docx.rb +13 -0
  67. data/lib/uniword/drawingml/blip.rb +0 -1
  68. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  69. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  70. data/lib/uniword/drawingml/theme.rb +12 -6
  71. data/lib/uniword/errors.rb +8 -1
  72. data/lib/uniword/footer.rb +4 -0
  73. data/lib/uniword/header.rb +4 -0
  74. data/lib/uniword/hyperlink.rb +1 -1
  75. data/lib/uniword/image.rb +6 -6
  76. data/lib/uniword/images/image_manager.rb +13 -13
  77. data/lib/uniword/lazy_loader.rb +1 -2
  78. data/lib/uniword/model_attribute_access.rb +20 -4
  79. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  80. data/lib/uniword/ooxml/element_order.rb +94 -0
  81. data/lib/uniword/ooxml/part_definition.rb +329 -0
  82. data/lib/uniword/ooxml/part_registry.rb +504 -0
  83. data/lib/uniword/ooxml/relationships/image_relationship.rb +6 -3
  84. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  85. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  86. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  87. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  88. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  89. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  90. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  91. data/lib/uniword/ooxml/types.rb +10 -0
  92. data/lib/uniword/ooxml.rb +3 -0
  93. data/lib/uniword/properties/alignment.rb +8 -3
  94. data/lib/uniword/properties/border.rb +52 -6
  95. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  96. data/lib/uniword/properties/color_value.rb +8 -3
  97. data/lib/uniword/properties/highlight.rb +9 -5
  98. data/lib/uniword/properties/shading.rb +4 -3
  99. data/lib/uniword/properties/tab_stop.rb +6 -1
  100. data/lib/uniword/properties/table_justification.rb +4 -1
  101. data/lib/uniword/properties/underline.rb +4 -2
  102. data/lib/uniword/properties/vertical_align.rb +6 -3
  103. data/lib/uniword/review/review_manager.rb +23 -9
  104. data/lib/uniword/revision.rb +9 -9
  105. data/lib/uniword/schema/model_generator.rb +31 -15
  106. data/lib/uniword/shared_types/hex_color.rb +4 -1
  107. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  108. data/lib/uniword/shared_types/point_measure.rb +4 -1
  109. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  110. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  111. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  112. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  113. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  114. data/lib/uniword/template/variable_resolver.rb +40 -7
  115. data/lib/uniword/themes/theme_transformation.rb +55 -41
  116. data/lib/uniword/toc/toc_generator.rb +6 -5
  117. data/lib/uniword/transformation/mhtml_element_renderer.rb +1 -5
  118. data/lib/uniword/transformation/mhtml_metadata_builder.rb +6 -6
  119. data/lib/uniword/validation/engine.rb +35 -0
  120. data/lib/uniword/validation/link_checker.rb +1 -1
  121. data/lib/uniword/validation/opc_validator.rb +16 -2
  122. data/lib/uniword/validation/report/layer_result.rb +0 -1
  123. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  124. data/lib/uniword/validation/report/verification_report.rb +0 -2
  125. data/lib/uniword/validation/report.rb +20 -0
  126. data/lib/uniword/validation/rules/base.rb +10 -2
  127. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  128. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  129. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  130. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  133. data/lib/uniword/validation/rules/document_context.rb +7 -0
  134. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  135. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  136. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  137. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  140. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  141. data/lib/uniword/validation/rules/model_context.rb +32 -0
  142. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  143. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  144. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  145. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  146. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  147. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  148. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  149. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  150. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  151. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  152. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  153. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  154. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  155. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  156. data/lib/uniword/validation/rules.rb +61 -23
  157. data/lib/uniword/validation/schema_registry.rb +11 -0
  158. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  159. data/lib/uniword/validation/validators/xml_schema_validator.rb +46 -2
  160. data/lib/uniword/validation/validators.rb +0 -9
  161. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  162. data/lib/uniword/validation.rb +7 -2
  163. data/lib/uniword/version.rb +1 -1
  164. data/lib/uniword/vml/imagedata.rb +0 -1
  165. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  166. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  167. data/lib/uniword/wordprocessingml/document_root.rb +121 -122
  168. data/lib/uniword/wordprocessingml/document_styling.rb +246 -0
  169. data/lib/uniword/wordprocessingml/endnotes.rb +4 -1
  170. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  171. data/lib/uniword/wordprocessingml/footnotes.rb +4 -1
  172. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  173. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  174. data/lib/uniword/wordprocessingml/level.rb +6 -20
  175. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  176. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  177. data/lib/uniword/wordprocessingml/numbering_elements.rb +0 -15
  178. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  179. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  180. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  181. data/lib/uniword/wordprocessingml/run.rb +3 -0
  182. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  183. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  184. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  185. data/lib/uniword/wordprocessingml/style.rb +2 -1
  186. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  187. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  188. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  189. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  190. data/lib/uniword/wordprocessingml.rb +5 -2
  191. data/lib/uniword.rb +27 -2
  192. metadata +52 -19
  193. data/config/validation_rules.yml +0 -60
  194. data/config/warning_rules.yml +0 -57
  195. data/lib/uniword/validation/document_validator.rb +0 -272
  196. data/lib/uniword/validation/structural_validator.rb +0 -116
  197. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  198. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  199. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  200. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  201. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  202. data/lib/uniword/validators/element_validator.rb +0 -93
  203. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  204. data/lib/uniword/validators/table_validator.rb +0 -134
  205. data/lib/uniword/validators.rb +0 -9
  206. data/lib/uniword/warnings/warning.rb +0 -130
  207. data/lib/uniword/warnings/warning_collector.rb +0 -234
  208. data/lib/uniword/warnings/warning_report.rb +0 -159
  209. data/lib/uniword/warnings.rb +0 -9
@@ -12,6 +12,7 @@ module Uniword
12
12
  autoload :FeatureFacade, "#{__dir__}/document_root/feature_facade"
13
13
 
14
14
  include FeatureFacade
15
+ include DocumentStyling
15
16
  include Uniword::DocumentInput
16
17
 
17
18
  attribute :mc_ignorable, Uniword::Ooxml::Types::McIgnorable
@@ -94,28 +95,14 @@ module Uniword
94
95
  # Additional attributes for DOCX metadata (not part of document.xml)
95
96
  # These are stored in separate files within the DOCX package
96
97
  attr_accessor :theme, :raw_html, :revisions, :comments
97
- # Headers and footers (stored as hash: type => Header/Footer)
98
- # Single-section only. For multi-section, use header_footer_parts.
99
- attr_accessor :headers, :footers
100
- # Multi-section headers/footers (ordered array):
101
- # [{r_id: "rIdH1", target: "header1.xml", rel_type: "...",
102
- # content_type: "...", content: Header|Footer}]
103
- attr_accessor :header_footer_parts
104
98
  # Footnotes and endnotes (separate XML parts in DOCX package)
105
99
  attr_accessor :footnotes, :endnotes
106
- # Image parts to embed in the DOCX package
107
- # Hash: r_id => { path: String, data: String, content_type: String, target: String }
108
- attr_accessor :image_parts
109
- # Chart parts to embed in the DOCX package
110
- # Hash: r_id => { xml: String, target: String }
111
- attr_accessor :chart_parts
112
100
  # Bibliography sources for sources.xml
113
101
  attr_accessor :bibliography_sources
114
- # OLE/embedded object binaries (word/embeddings/*)
115
- attr_accessor :embeddings
116
102
  # Round-trip parts (copied from DocxPackage during load)
117
103
  attr_accessor :settings, :font_table, :web_settings, :document_rels, :theme_rels,
118
- :package_rels, :content_types, :custom_properties, :custom_xml_items
104
+ :package_rels, :content_types, :custom_properties, :custom_xml_items,
105
+ :settings_rels, :footnotes_rels, :endnotes_rels
119
106
  # Central ID allocator — preserves IDs across build/save cycle
120
107
  attr_accessor :allocator
121
108
 
@@ -123,6 +110,100 @@ module Uniword
123
110
  # (removing from attr_accessor to avoid shadowing custom getters)
124
111
  attr_writer :app_properties, :core_properties, :bookmarks
125
112
 
113
+ # Unified header/footer part store — the single storage path
114
+ # for both round-tripped and builder-created headers/footers.
115
+ #
116
+ # @return [Docx::HeaderFooterPartCollection] ordered part store
117
+ def header_footer_parts
118
+ @header_footer_parts ||= Docx::HeaderFooterPartCollection.new
119
+ end
120
+
121
+ # Replace the whole store (accepts HeaderFooterPart objects and
122
+ # legacy part hashes).
123
+ def header_footer_parts=(parts)
124
+ header_footer_parts.replace_all(parts)
125
+ end
126
+
127
+ # Headers view over the unified store (Hash-style by sectPr
128
+ # type, Array-style over parts).
129
+ #
130
+ # @return [Docx::HeaderFooterView]
131
+ def headers
132
+ @headers ||=
133
+ Docx::HeaderFooterView.new(header_footer_parts, :header)
134
+ end
135
+
136
+ # Bulk-assign headers (nil clears; Hash of type => model; Array
137
+ # of models/parts).
138
+ def headers=(value)
139
+ headers.replace(value)
140
+ end
141
+
142
+ # Footers view over the unified store.
143
+ #
144
+ # @return [Docx::HeaderFooterView]
145
+ def footers
146
+ @footers ||=
147
+ Docx::HeaderFooterView.new(header_footer_parts, :footer)
148
+ end
149
+
150
+ # Bulk-assign footers (see #headers=).
151
+ def footers=(value)
152
+ footers.replace(value)
153
+ end
154
+
155
+ # Image parts (word/media/* binaries), keyed by rId.
156
+ #
157
+ # @return [Docx::PartCollection] rId => Docx::ImagePart
158
+ def image_parts
159
+ @image_parts ||= Docx::PartCollection.new(:r_id, Docx::ImagePart)
160
+ end
161
+
162
+ # Bulk-assign image parts (Hash of rId => ImagePart/legacy part
163
+ # hash; nil clears).
164
+ def image_parts=(value)
165
+ image_parts.replace_all(value)
166
+ end
167
+
168
+ # Chart parts to embed in the DOCX package, keyed by rId.
169
+ #
170
+ # @return [Docx::PartCollection] rId => ChartPart
171
+ def chart_parts
172
+ @chart_parts ||= Docx::PartCollection.new(:r_id, Docx::ChartPart)
173
+ end
174
+
175
+ # Bulk-assign chart parts (Hash of rId => ChartPart/hash; nil clears).
176
+ def chart_parts=(value)
177
+ chart_parts.replace_all(value)
178
+ end
179
+
180
+ # OLE/embedded object binaries (word/embeddings/*), keyed by target.
181
+ #
182
+ # @return [Docx::PartCollection] target => Part
183
+ def embeddings
184
+ @embeddings ||= Docx::PartCollection.new(:target, Docx::Part)
185
+ end
186
+
187
+ # Bulk-assign embeddings (Hash of target => Part/binary; nil clears).
188
+ def embeddings=(value)
189
+ embeddings.replace_all(value)
190
+ end
191
+
192
+ # Raw passthrough parts (unmodelled package parts carried
193
+ # byte-for-byte), keyed by package path. Mirrored from the
194
+ # package on load and back on save via the registry-driven
195
+ # document↔package copies (:raw_parts definition).
196
+ #
197
+ # @return [Docx::PartCollection] package path => Docx::RawPart
198
+ def raw_parts
199
+ @raw_parts ||= Docx::PartCollection.new(:path, Docx::RawPart)
200
+ end
201
+
202
+ # Bulk-assign raw parts (Hash of path => RawPart/hash; nil clears).
203
+ def raw_parts=(value)
204
+ raw_parts.replace_all(value)
205
+ end
206
+
126
207
  # Get app_properties (lazy initialization)
127
208
  #
128
209
  # @return [Uniword::Ooxml::AppProperties] The app properties object
@@ -168,17 +249,24 @@ module Uniword
168
249
  #
169
250
  # @param path [String] Output file path
170
251
  # @param format [Symbol] Format (:docx, :mhtml, :auto)
171
- def save(path, format: :auto, profile: nil)
252
+ # @param profile [Docx::Profile, nil] Profile for reconciliation
253
+ # @param validate [Boolean, nil] Run the package integrity gate before
254
+ # writing; nil falls back to Uniword.configuration.validate_on_save
255
+ # @return [void]
256
+ def save(path, format: :auto, profile: nil, validate: nil)
172
257
  writer = DocumentWriter.new(self)
173
- writer.save(path, format: format, profile: profile)
258
+ writer.save(path, format: format, profile: profile, validate: validate)
174
259
  end
175
260
 
176
261
  # Save document to DOCX file using DocxPackage
177
262
  #
178
263
  # @param path [String] Output file path
179
264
  # @param profile [Docx::Profile, nil] Profile for reconciliation
180
- def to_file(path, profile: nil)
181
- Docx::Package.to_file(self, path, profile: profile)
265
+ # @param validate [Boolean, nil] Run the package integrity gate before
266
+ # writing; nil falls back to Uniword.configuration.validate_on_save
267
+ # @return [void]
268
+ def to_file(path, profile: nil, validate: nil)
269
+ Docx::Package.to_file(self, path, profile: profile, validate: validate)
182
270
  end
183
271
 
184
272
  # Get all paragraph text
@@ -226,26 +314,26 @@ module Uniword
226
314
  end
227
315
 
228
316
  # Check if document structure is valid.
229
- # Runs structural checks via Validation::StructuralValidator.
317
+ # Runs model-level validation rules via Validation::Engine.
230
318
  # Use the verify CLI command for full OPC + XSD + semantic validation.
231
319
  #
232
320
  # @return [Boolean] true if document has valid structure
233
321
  def valid?
234
- Validation::StructuralValidator.new(self).valid?
322
+ validation_errors.empty?
235
323
  end
236
324
 
237
325
  # Get structural validation errors.
238
326
  #
239
327
  # @return [Array<String>] Error messages
240
328
  def validation_errors
241
- Validation::StructuralValidator.new(self).errors
329
+ structural_issues.select(&:error?).map(&:message)
242
330
  end
243
331
 
244
332
  # Get structural validation warnings.
245
333
  #
246
334
  # @return [Array<String>] Warning messages
247
335
  def validation_warnings
248
- Validation::StructuralValidator.new(self).warnings
336
+ structural_issues.reject(&:error?).map(&:message)
249
337
  end
250
338
 
251
339
  # Get bookmarks from document paragraphs
@@ -265,104 +353,6 @@ module Uniword
265
353
 
266
354
  # Apply theme to document
267
355
  #
268
- # Applies a Uniword theme by name, updating doc defaults and
269
- # built-in heading/hyperlink styles to reference the theme.
270
- #
271
- # @param name [String, Symbol] Theme slug (e.g., 'meridian', 'corporate')
272
- # @param options [Hash] optional overrides
273
- # @option options [Hash] :colors override specific color keys
274
- # @option options [String] :major_font override major font
275
- # @option options [String] :minor_font override minor font
276
- # @return [self] For method chaining
277
- def apply_theme(name, **options)
278
- friendly = Themes::Theme.load(name.to_s)
279
-
280
- options[:colors]&.each do |key, value|
281
- friendly.color_scheme[key.to_s] = value
282
- end
283
- friendly.font_scheme.major_font = options[:major_font] if options[:major_font]
284
- friendly.font_scheme.minor_font = options[:minor_font] if options[:minor_font]
285
-
286
- word_theme = Themes::ThemeTransformation.new.to_word(friendly)
287
- Themes::ThemeApplicator.new.apply(word_theme, self)
288
- self
289
- end
290
-
291
- # Apply theme from .thmx file
292
- #
293
- # @param path [String] Path to .thmx file
294
- # @param variant [String, Integer, nil] Optional variant
295
- # @return [self] For method chaining
296
- def apply_theme_file(path, variant: nil)
297
- loader = Themes::ThemeLoader.new
298
- self.theme = if variant
299
- loader.load_with_variant(path, variant)
300
- else
301
- loader.load(path)
302
- end
303
- self
304
- end
305
-
306
- # Apply StyleSet to document
307
- #
308
- # @param name [String, Symbol] StyleSet slug (e.g., 'signature', 'heritage')
309
- # @param strategy [Symbol] Application strategy (:keep_existing, :replace, :rename)
310
- # @return [self] For method chaining
311
- def apply_styleset(name, strategy: :keep_existing)
312
- styleset = Uniword::Stylesets::YamlStyleSetLoader.load_bundled(name.to_s)
313
- styleset.apply_to(self, strategy: strategy)
314
- self
315
- end
316
-
317
- # Auto-transition from MS theme to Uniword equivalent
318
- #
319
- # Detects the MS theme in the document's embedded theme and replaces
320
- # it with the corresponding Uniword theme (font-substituted, renamed).
321
- #
322
- # @return [Hash, nil] { uniword_slug:, ms_name: } or nil if no match
323
- #
324
- # @example
325
- # result = doc.auto_transition_theme
326
- # puts "Transitioned from #{result[:ms_name]} to #{result[:uniword_slug]}"
327
- def auto_transition_theme
328
- Resource::ThemeTransition.auto_transition!(self)
329
- end
330
-
331
- # Apply theme from another document
332
- #
333
- # @param source_path [String] Path to source .docx file
334
- # @return [self] For method chaining
335
- def apply_theme_from(source_path)
336
- source_doc = Uniword.load(source_path)
337
- self.theme = source_doc.theme.dup if source_doc.theme
338
- self
339
- end
340
-
341
- # Apply styles from another document
342
- #
343
- # @param source_path [String] Path to source .docx file
344
- # @param strategy [Symbol] Conflict resolution strategy
345
- # @return [self] For method chaining
346
- def apply_styles_from(source_path, strategy: :keep_existing)
347
- source_doc = Uniword.load(source_path)
348
- styles_configuration.merge(source_doc.styles_configuration,
349
- conflict_resolution: strategy)
350
- self
351
- end
352
-
353
- # Apply both theme and styles from a template document
354
- #
355
- # @param template_path [String] Path to template .docx file
356
- # @param strategy [Symbol] Conflict resolution strategy for styles
357
- # @return [self] For method chaining
358
- def apply_template(template_path, strategy: :keep_existing)
359
- template_doc = Uniword.load(template_path)
360
- self.theme = template_doc.theme.dup if template_doc.theme
361
- styles_configuration.merge(template_doc.styles_configuration,
362
- conflict_resolution: strategy)
363
- self
364
- end
365
-
366
356
  # Custom inspect for readable output
367
357
  #
368
358
  # @return [String] Human-readable representation
@@ -376,6 +366,15 @@ module Uniword
376
366
  def to_html_document
377
367
  Uniword::Transformation::OoxmlToHtmlConverter.document_to_html(self)
378
368
  end
369
+
370
+ private
371
+
372
+ # Run model-level validation rules against this document.
373
+ #
374
+ # @return [Array<Validation::Report::ValidationIssue>] issues found
375
+ def structural_issues
376
+ Validation::Engine.run(Validation::Rules::ModelContext.new(self))
377
+ end
379
378
  end
380
379
  end
381
380
  end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Wordprocessingml
5
+ # Theme and style application for DocumentRoot — Word's
6
+ # Design/Layout/Styles surfaces as document-level API.
7
+ #
8
+ # Extracted from DocumentRoot to keep the document model focused:
9
+ # bundled and file-based theme application, stylesets, font and
10
+ # color schemes, font replacement, page setup, and style
11
+ # management (rename/remove).
12
+ module DocumentStyling
13
+ # Applies a Uniword theme by name, updating doc defaults and
14
+ # built-in heading/hyperlink styles to reference the theme.
15
+ #
16
+ # @param name [String, Symbol] Theme slug (e.g., 'meridian',
17
+ # 'corporate')
18
+ # @param options [Hash] optional overrides
19
+ # @option options [Hash] :colors override specific color keys
20
+ # @option options [String] :major_font override major font
21
+ # @option options [String] :minor_font override minor font
22
+ # @return [self] For method chaining
23
+ def apply_theme(name, **options)
24
+ friendly = Themes::Theme.load(name.to_s)
25
+
26
+ options[:colors]&.each do |key, value|
27
+ friendly.color_scheme[key.to_s] = value
28
+ end
29
+ if options[:major_font]
30
+ friendly.font_scheme.major_font = options[:major_font]
31
+ end
32
+ if options[:minor_font]
33
+ friendly.font_scheme.minor_font = options[:minor_font]
34
+ end
35
+
36
+ word_theme = Themes::ThemeTransformation.new.to_word(friendly)
37
+ Themes::ThemeApplicator.new.apply(word_theme, self)
38
+ self
39
+ end
40
+
41
+ # Apply theme from .thmx file
42
+ #
43
+ # @param path [String] Path to .thmx file
44
+ # @param variant [String, Integer, nil] Optional variant
45
+ # @return [self] For method chaining
46
+ def apply_theme_file(path, variant: nil)
47
+ loader = Themes::ThemeLoader.new
48
+ self.theme = if variant
49
+ loader.load_with_variant(path, variant)
50
+ else
51
+ loader.load(path)
52
+ end
53
+ self
54
+ end
55
+
56
+ # Apply StyleSet to document
57
+ #
58
+ # @param name [String, Symbol] StyleSet slug (e.g., 'signature',
59
+ # 'heritage')
60
+ # @param strategy [Symbol] Application strategy (:keep_existing,
61
+ # :replace, :rename)
62
+ # @return [self] For method chaining
63
+ def apply_styleset(name, strategy: :keep_existing)
64
+ styleset = Uniword::Stylesets::YamlStyleSetLoader
65
+ .load_bundled(name.to_s)
66
+ styleset.apply_to(self, strategy: strategy)
67
+ self
68
+ end
69
+
70
+ # Apply a bundled font scheme to the document's theme
71
+ #
72
+ # Mirrors Word's Design → Fonts gallery: replaces only the theme's
73
+ # fontScheme — colors and formats are untouched. Creates the theme
74
+ # part from the bundled Office theme when the document has none.
75
+ #
76
+ # @param name [String, Symbol] Font scheme slug (data/font_schemes/)
77
+ # @return [self] For method chaining
78
+ # @raise [ArgumentError] if the scheme is unknown
79
+ def apply_font_scheme(name)
80
+ fonts = Resource::FontSchemeLoader.load(name.to_s)
81
+ transformation = Themes::ThemeTransformation.new
82
+ ensure_theme!(transformation)
83
+ theme.theme_elements.font_scheme =
84
+ transformation.build_font_scheme(fonts)
85
+ self
86
+ end
87
+
88
+ # Apply a bundled color scheme to the document's theme
89
+ #
90
+ # Mirrors Word's Design → Colors gallery: replaces only the theme's
91
+ # clrScheme — fonts and formats are untouched. Creates the theme
92
+ # part from the bundled Office theme when the document has none.
93
+ #
94
+ # @param name [String, Symbol] Color scheme slug (data/color_schemes/)
95
+ # @return [self] For method chaining
96
+ # @raise [ArgumentError] if the scheme is unknown
97
+ def apply_color_scheme(name)
98
+ colors = Resource::ColorSchemeLoader.load(name.to_s)
99
+ transformation = Themes::ThemeTransformation.new
100
+ ensure_theme!(transformation)
101
+ theme.theme_elements.clr_scheme =
102
+ transformation.build_color_scheme(colors)
103
+ self
104
+ end
105
+
106
+ # Replace one font family with another across the document
107
+ #
108
+ # Mirrors Word's Home → Replace → Replace Fonts: rewrites rFonts
109
+ # references in styles and defaults, body content, headers,
110
+ # footers, notes, comments, and numbering. Theme font references
111
+ # (asciiTheme etc.) are untouched — use #apply_font_scheme for
112
+ # those.
113
+ #
114
+ # @param from [String] Font family to replace (exact match)
115
+ # @param to [String] Replacement font family
116
+ # @return [Integer] Number of rFonts attribute values rewritten
117
+ def replace_font(from:, to:)
118
+ replacer = FontReplacer.new(from: from, to: to)
119
+ replacer.replace(self)
120
+ end
121
+
122
+ # Apply uniform page setup to every section of the document
123
+ #
124
+ # Mirrors Word's Layout dialog: named paper sizes, orientation
125
+ # (with Word-style dimension swap), and margins.
126
+ #
127
+ # @param size [String, nil] Paper size: letter, legal, a4, a5,
128
+ # executive
129
+ # @param orientation [String, nil] "portrait" or "landscape"
130
+ # @param margins [Integer, String, nil] Uniform margin for all
131
+ # sides (twips, or "1in" / "2.5cm" / "25mm")
132
+ # @param top, right, bottom, left [Integer, String, nil] Per-side
133
+ # margin overrides
134
+ # @return [Integer] Number of sections updated
135
+ def apply_page_setup(size: nil, orientation: nil, margins: nil,
136
+ top: nil, right: nil, bottom: nil, left: nil)
137
+ setup = PageSetup.new(size: size, orientation: orientation,
138
+ margins: margins, top: top, right: right,
139
+ bottom: bottom, left: left)
140
+ setup.apply(self)
141
+ end
142
+
143
+ # Remove one style from the document's style definitions
144
+ #
145
+ # Default styles (w:default="1") are never removed.
146
+ #
147
+ # @param style_id [String] Style id (w:styleId)
148
+ # @return [Style, nil] The removed style, or nil when not
149
+ # removed (absent or protected)
150
+ def remove_style(style_id)
151
+ style = styles_configuration.style(style_id)
152
+ return unless StyleCleanup.new(self).remove?(style_id)
153
+
154
+ style
155
+ end
156
+
157
+ # Rename a style's display name (w:name) — Word's style rename.
158
+ # References (pStyle/rStyle/tblStyle) target the styleId, so they
159
+ # keep working unchanged.
160
+ #
161
+ # @param identifier [String] Style id (w:styleId) or current
162
+ # display name (w:name)
163
+ # @param new_name [String] New display name
164
+ # @return [Style, nil] The renamed style, or nil when not found
165
+ def rename_style(identifier, new_name)
166
+ style = styles_configuration.style(identifier)
167
+ return unless style
168
+
169
+ style.name = StyleName.new(val: new_name)
170
+ style
171
+ end
172
+
173
+ # Remove every style that no content references — Word's
174
+ # Styles-pane decluttering as one call. Default styles are kept.
175
+ #
176
+ # @return [Array<String>] Ids of removed styles
177
+ def remove_unused_styles
178
+ StyleCleanup.new(self).remove_unused
179
+ end
180
+
181
+ # Auto-transition from MS theme to Uniword equivalent
182
+ #
183
+ # Detects the MS theme in the document's embedded theme and replaces
184
+ # it with the corresponding Uniword theme (font-substituted, renamed).
185
+ #
186
+ # @return [Hash, nil] { uniword_slug:, ms_name: } or nil if no match
187
+ #
188
+ # @example
189
+ # result = doc.auto_transition_theme
190
+ # puts "Transitioned from " \
191
+ # "#{result[:ms_name]} to #{result[:uniword_slug]}"
192
+ def auto_transition_theme
193
+ Resource::ThemeTransition.auto_transition!(self)
194
+ end
195
+
196
+ # Apply theme from another document
197
+ #
198
+ # @param source_path [String] Path to source .docx file
199
+ # @return [self] For method chaining
200
+ def apply_theme_from(source_path)
201
+ source_doc = Uniword.load(source_path)
202
+ self.theme = source_doc.theme.dup if source_doc.theme
203
+ self
204
+ end
205
+
206
+ # Apply styles from another document
207
+ #
208
+ # @param source_path [String] Path to source .docx file
209
+ # @param strategy [Symbol] Conflict resolution strategy
210
+ # @return [self] For method chaining
211
+ def apply_styles_from(source_path, strategy: :keep_existing)
212
+ source_doc = Uniword.load(source_path)
213
+ styles_configuration.merge(source_doc.styles_configuration,
214
+ conflict_resolution: strategy)
215
+ self
216
+ end
217
+
218
+ # Apply both theme and styles from a template document
219
+ #
220
+ # @param template_path [String] Path to template .docx file
221
+ # @param strategy [Symbol] Conflict resolution strategy for styles
222
+ # @return [self] For method chaining
223
+ def apply_template(template_path, strategy: :keep_existing)
224
+ template_doc = Uniword.load(template_path)
225
+ self.theme = template_doc.theme.dup if template_doc.theme
226
+ styles_configuration.merge(template_doc.styles_configuration,
227
+ conflict_resolution: strategy)
228
+ self
229
+ end
230
+
231
+ private
232
+
233
+ # Ensure the document carries a theme part, creating it from a
234
+ # fresh parse of the bundled Office theme when absent (every
235
+ # document gets its own copy — no shared state).
236
+ #
237
+ # @param transformation [Themes::ThemeTransformation] Converter
238
+ # @return [void]
239
+ def ensure_theme!(transformation)
240
+ return if theme&.theme_elements
241
+
242
+ self.theme = transformation.default_office_theme
243
+ end
244
+ end
245
+ end
246
+ end
@@ -32,8 +32,11 @@ module Uniword
32
32
  end
33
33
 
34
34
  missing = endnote_entries.size - counts["endnote"]
35
+ return unless missing.positive?
36
+
37
+ order = Ooxml::ElementOrder.mutable_order(self)
35
38
  missing.times do
36
- element_order << Lutaml::Xml::Element.new("Element", "endnote")
39
+ order << Lutaml::Xml::Element.new("Element", "endnote")
37
40
  end
38
41
  end
39
42