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
@@ -94,28 +94,17 @@ module Uniword
94
94
  # Additional attributes for DOCX metadata (not part of document.xml)
95
95
  # These are stored in separate files within the DOCX package
96
96
  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
97
  # Footnotes and endnotes (separate XML parts in DOCX package)
105
98
  attr_accessor :footnotes, :endnotes
106
99
  # Image parts to embed in the DOCX package
107
100
  # Hash: r_id => { path: String, data: String, content_type: String, target: String }
108
101
  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
102
  # Bibliography sources for sources.xml
113
103
  attr_accessor :bibliography_sources
114
- # OLE/embedded object binaries (word/embeddings/*)
115
- attr_accessor :embeddings
116
104
  # Round-trip parts (copied from DocxPackage during load)
117
105
  attr_accessor :settings, :font_table, :web_settings, :document_rels, :theme_rels,
118
- :package_rels, :content_types, :custom_properties, :custom_xml_items
106
+ :package_rels, :content_types, :custom_properties, :custom_xml_items,
107
+ :settings_rels, :footnotes_rels, :endnotes_rels
119
108
  # Central ID allocator — preserves IDs across build/save cycle
120
109
  attr_accessor :allocator
121
110
 
@@ -123,6 +112,72 @@ module Uniword
123
112
  # (removing from attr_accessor to avoid shadowing custom getters)
124
113
  attr_writer :app_properties, :core_properties, :bookmarks
125
114
 
115
+ # Unified header/footer part store — the single storage path
116
+ # for both round-tripped and builder-created headers/footers.
117
+ #
118
+ # @return [Docx::HeaderFooterPartCollection] ordered part store
119
+ def header_footer_parts
120
+ @header_footer_parts ||= Docx::HeaderFooterPartCollection.new
121
+ end
122
+
123
+ # Replace the whole store (accepts HeaderFooterPart objects and
124
+ # legacy part hashes).
125
+ def header_footer_parts=(parts)
126
+ header_footer_parts.replace_all(parts)
127
+ end
128
+
129
+ # Headers view over the unified store (Hash-style by sectPr
130
+ # type, Array-style over parts).
131
+ #
132
+ # @return [Docx::HeaderFooterView]
133
+ def headers
134
+ @headers ||=
135
+ Docx::HeaderFooterView.new(header_footer_parts, :header)
136
+ end
137
+
138
+ # Bulk-assign headers (nil clears; Hash of type => model; Array
139
+ # of models/parts).
140
+ def headers=(value)
141
+ headers.replace(value)
142
+ end
143
+
144
+ # Footers view over the unified store.
145
+ #
146
+ # @return [Docx::HeaderFooterView]
147
+ def footers
148
+ @footers ||=
149
+ Docx::HeaderFooterView.new(header_footer_parts, :footer)
150
+ end
151
+
152
+ # Bulk-assign footers (see #headers=).
153
+ def footers=(value)
154
+ footers.replace(value)
155
+ end
156
+
157
+ # Chart parts to embed in the DOCX package, keyed by rId.
158
+ #
159
+ # @return [Docx::PartCollection] rId => ChartPart
160
+ def chart_parts
161
+ @chart_parts ||= Docx::PartCollection.new(:r_id, Docx::ChartPart)
162
+ end
163
+
164
+ # Bulk-assign chart parts (Hash of rId => ChartPart/hash; nil clears).
165
+ def chart_parts=(value)
166
+ chart_parts.replace_all(value)
167
+ end
168
+
169
+ # OLE/embedded object binaries (word/embeddings/*), keyed by target.
170
+ #
171
+ # @return [Docx::PartCollection] target => Part
172
+ def embeddings
173
+ @embeddings ||= Docx::PartCollection.new(:target, Docx::Part)
174
+ end
175
+
176
+ # Bulk-assign embeddings (Hash of target => Part/binary; nil clears).
177
+ def embeddings=(value)
178
+ embeddings.replace_all(value)
179
+ end
180
+
126
181
  # Get app_properties (lazy initialization)
127
182
  #
128
183
  # @return [Uniword::Ooxml::AppProperties] The app properties object
@@ -168,17 +223,24 @@ module Uniword
168
223
  #
169
224
  # @param path [String] Output file path
170
225
  # @param format [Symbol] Format (:docx, :mhtml, :auto)
171
- def save(path, format: :auto, profile: nil)
226
+ # @param profile [Docx::Profile, nil] Profile for reconciliation
227
+ # @param validate [Boolean, nil] Run the package integrity gate before
228
+ # writing; nil falls back to Uniword.configuration.validate_on_save
229
+ # @return [void]
230
+ def save(path, format: :auto, profile: nil, validate: nil)
172
231
  writer = DocumentWriter.new(self)
173
- writer.save(path, format: format, profile: profile)
232
+ writer.save(path, format: format, profile: profile, validate: validate)
174
233
  end
175
234
 
176
235
  # Save document to DOCX file using DocxPackage
177
236
  #
178
237
  # @param path [String] Output file path
179
238
  # @param profile [Docx::Profile, nil] Profile for reconciliation
180
- def to_file(path, profile: nil)
181
- Docx::Package.to_file(self, path, profile: profile)
239
+ # @param validate [Boolean, nil] Run the package integrity gate before
240
+ # writing; nil falls back to Uniword.configuration.validate_on_save
241
+ # @return [void]
242
+ def to_file(path, profile: nil, validate: nil)
243
+ Docx::Package.to_file(self, path, profile: profile, validate: validate)
182
244
  end
183
245
 
184
246
  # Get all paragraph text
@@ -226,26 +288,26 @@ module Uniword
226
288
  end
227
289
 
228
290
  # Check if document structure is valid.
229
- # Runs structural checks via Validation::StructuralValidator.
291
+ # Runs model-level validation rules via Validation::Engine.
230
292
  # Use the verify CLI command for full OPC + XSD + semantic validation.
231
293
  #
232
294
  # @return [Boolean] true if document has valid structure
233
295
  def valid?
234
- Validation::StructuralValidator.new(self).valid?
296
+ validation_errors.empty?
235
297
  end
236
298
 
237
299
  # Get structural validation errors.
238
300
  #
239
301
  # @return [Array<String>] Error messages
240
302
  def validation_errors
241
- Validation::StructuralValidator.new(self).errors
303
+ structural_issues.select(&:error?).map(&:message)
242
304
  end
243
305
 
244
306
  # Get structural validation warnings.
245
307
  #
246
308
  # @return [Array<String>] Warning messages
247
309
  def validation_warnings
248
- Validation::StructuralValidator.new(self).warnings
310
+ structural_issues.reject(&:error?).map(&:message)
249
311
  end
250
312
 
251
313
  # Get bookmarks from document paragraphs
@@ -314,6 +376,113 @@ module Uniword
314
376
  self
315
377
  end
316
378
 
379
+ # Apply a bundled font scheme to the document's theme
380
+ #
381
+ # Mirrors Word's Design → Fonts gallery: replaces only the theme's
382
+ # fontScheme — colors and formats are untouched. Creates the theme
383
+ # part from the bundled Office theme when the document has none.
384
+ #
385
+ # @param name [String, Symbol] Font scheme slug (data/font_schemes/)
386
+ # @return [self] For method chaining
387
+ # @raise [ArgumentError] if the scheme is unknown
388
+ def apply_font_scheme(name)
389
+ fonts = Resource::FontSchemeLoader.load(name.to_s)
390
+ transformation = Themes::ThemeTransformation.new
391
+ ensure_theme!(transformation)
392
+ theme.theme_elements.font_scheme =
393
+ transformation.build_font_scheme(fonts)
394
+ self
395
+ end
396
+
397
+ # Apply a bundled color scheme to the document's theme
398
+ #
399
+ # Mirrors Word's Design → Colors gallery: replaces only the theme's
400
+ # clrScheme — fonts and formats are untouched. Creates the theme
401
+ # part from the bundled Office theme when the document has none.
402
+ #
403
+ # @param name [String, Symbol] Color scheme slug (data/color_schemes/)
404
+ # @return [self] For method chaining
405
+ # @raise [ArgumentError] if the scheme is unknown
406
+ def apply_color_scheme(name)
407
+ colors = Resource::ColorSchemeLoader.load(name.to_s)
408
+ transformation = Themes::ThemeTransformation.new
409
+ ensure_theme!(transformation)
410
+ theme.theme_elements.clr_scheme =
411
+ transformation.build_color_scheme(colors)
412
+ self
413
+ end
414
+
415
+ # Replace one font family with another across the document
416
+ #
417
+ # Mirrors Word's Home → Replace → Replace Fonts: rewrites rFonts
418
+ # references in styles and defaults, body content, headers,
419
+ # footers, notes, comments, and numbering. Theme font references
420
+ # (asciiTheme etc.) are untouched — use #apply_font_scheme for
421
+ # those.
422
+ #
423
+ # @param from [String] Font family to replace (exact match)
424
+ # @param to [String] Replacement font family
425
+ # @return [Integer] Number of rFonts attribute values rewritten
426
+ def replace_font(from:, to:)
427
+ replacer = FontReplacer.new(from: from, to: to)
428
+ replacer.replace(self)
429
+ end
430
+
431
+ # Apply uniform page setup to every section of the document
432
+ #
433
+ # Mirrors Word's Layout dialog: named paper sizes, orientation
434
+ # (with Word-style dimension swap), and margins.
435
+ #
436
+ # @param size [String, nil] Paper size: letter, legal, a4, a5,
437
+ # executive
438
+ # @param orientation [String, nil] "portrait" or "landscape"
439
+ # @param margins [Integer, String, nil] Uniform margin for all
440
+ # sides (twips, or "1in" / "2.5cm" / "25mm")
441
+ # @param top, right, bottom, left [Integer, String, nil] Per-side
442
+ # margin overrides
443
+ # @return [Integer] Number of sections updated
444
+ def apply_page_setup(size: nil, orientation: nil, margins: nil,
445
+ top: nil, right: nil, bottom: nil, left: nil)
446
+ setup = PageSetup.new(size: size, orientation: orientation,
447
+ margins: margins, top: top, right: right,
448
+ bottom: bottom, left: left)
449
+ setup.apply(self)
450
+ end
451
+
452
+ # Remove one style from the document's style definitions
453
+ #
454
+ # Default styles (w:default="1") are never removed.
455
+ #
456
+ # @param style_id [String] Style id (w:styleId)
457
+ # @return [Boolean] true when the style was removed
458
+ def remove_style(style_id)
459
+ StyleCleanup.new(self).remove?(style_id)
460
+ end
461
+
462
+ # Rename a style's display name (w:name) — Word's style rename.
463
+ # References (pStyle/rStyle/tblStyle) target the styleId, so they
464
+ # keep working unchanged.
465
+ #
466
+ # @param identifier [String] Style id (w:styleId) or current
467
+ # display name (w:name)
468
+ # @param new_name [String] New display name
469
+ # @return [Style, nil] The renamed style, or nil when not found
470
+ def rename_style(identifier, new_name)
471
+ style = styles_configuration.style(identifier)
472
+ return unless style
473
+
474
+ style.name = StyleName.new(val: new_name)
475
+ style
476
+ end
477
+
478
+ # Remove every style that no content references — Word's
479
+ # Styles-pane decluttering as one call. Default styles are kept.
480
+ #
481
+ # @return [Array<String>] Ids of removed styles
482
+ def remove_unused_styles
483
+ StyleCleanup.new(self).remove_unused
484
+ end
485
+
317
486
  # Auto-transition from MS theme to Uniword equivalent
318
487
  #
319
488
  # Detects the MS theme in the document's embedded theme and replaces
@@ -376,6 +545,27 @@ module Uniword
376
545
  def to_html_document
377
546
  Uniword::Transformation::OoxmlToHtmlConverter.document_to_html(self)
378
547
  end
548
+
549
+ private
550
+
551
+ # Ensure the document carries a theme part, creating it from a
552
+ # fresh parse of the bundled Office theme when absent (every
553
+ # document gets its own copy — no shared state).
554
+ #
555
+ # @param transformation [Themes::ThemeTransformation] Converter
556
+ # @return [void]
557
+ def ensure_theme!(transformation)
558
+ return if theme&.theme_elements
559
+
560
+ self.theme = transformation.default_office_theme
561
+ end
562
+
563
+ # Run model-level validation rules against this document.
564
+ #
565
+ # @return [Array<Validation::Report::ValidationIssue>] issues found
566
+ def structural_issues
567
+ Validation::Engine.run(Validation::Rules::ModelContext.new(self))
568
+ end
379
569
  end
380
570
  end
381
571
  end
@@ -32,6 +32,10 @@ module Uniword
32
32
  end
33
33
 
34
34
  missing = endnote_entries.size - counts["endnote"]
35
+ return unless missing.positive?
36
+
37
+ # element_order from the parser may be frozen; never mutate it.
38
+ self.element_order = element_order.dup
35
39
  missing.times do
36
40
  element_order << Lutaml::Xml::Element.new("Element", "endnote")
37
41
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Wordprocessingml
5
+ # Replaces one font family with another across a document —
6
+ # Word's Home → Replace → Replace Fonts as an API.
7
+ #
8
+ # Covers style definitions and defaults, body content (paragraphs,
9
+ # tables, hyperlinks), headers and footers, footnotes, endnotes,
10
+ # comments, and numbering levels. Theme font references
11
+ # (asciiTheme/hAnsiTheme/...) are NOT rewritten — those are
12
+ # controlled by the theme's font scheme
13
+ # (see DocumentRoot#apply_font_scheme).
14
+ #
15
+ # @example Replace Calibri with Carlito throughout a document
16
+ # replacer = FontReplacer.new(from: "Calibri", to: "Carlito")
17
+ # replacer.replace(document)
18
+ # puts replacer.count # number of rFonts values rewritten
19
+ class FontReplacer
20
+ # RunFonts attributes rewritten by font replacement.
21
+ FONT_ATTRIBUTES = %i[ascii h_ansi east_asia cs].freeze
22
+
23
+ # Create a replacer.
24
+ #
25
+ # @param from [String] Font family to replace (exact match,
26
+ # Word semantics)
27
+ # @param to [String] Replacement font family
28
+ def initialize(from:, to:)
29
+ @from = from.to_s
30
+ @to = to.to_s
31
+ @count = 0
32
+ end
33
+
34
+ # Number of rFonts attribute values replaced so far.
35
+ #
36
+ # @return [Integer] Replacement count
37
+ attr_reader :count
38
+
39
+ # Replace @from with @to throughout the document (mutated).
40
+ #
41
+ # @param document [DocumentRoot] Document to rewrite
42
+ # @return [Integer] Replacement count
43
+ def replace(document)
44
+ replace_in_styles(document.styles_configuration)
45
+ replace_in_numbering(document.numbering_configuration)
46
+ replace_in_container(document.body)
47
+ part_containers(document).each do |container|
48
+ replace_in_paragraphs(container)
49
+ end
50
+ count
51
+ end
52
+
53
+ private
54
+
55
+ # Styles: docDefaults and every style definition.
56
+ def replace_in_styles(styles)
57
+ return unless styles
58
+
59
+ replace_in_run_properties(styles.doc_defaults&.rPrDefault&.rPr)
60
+ (styles.styles || []).each do |style|
61
+ replace_in_run_properties(style.rPr)
62
+ end
63
+ end
64
+
65
+ # Numbering: every level of every abstract definition.
66
+ def replace_in_numbering(numbering)
67
+ return unless numbering
68
+
69
+ (numbering.definitions || []).each do |definition|
70
+ (definition.levels || []).each do |level|
71
+ replace_in_run_properties(level.rPr)
72
+ end
73
+ end
74
+ end
75
+
76
+ # Containers outside the body: header/footer parts, note
77
+ # entries, and comments — model objects holding paragraphs.
78
+ def part_containers(document)
79
+ header_footer_containers(document) +
80
+ note_containers(document) + comment_containers(document)
81
+ end
82
+
83
+ def header_footer_containers(document)
84
+ (document.header_footer_parts || []).filter_map do |part|
85
+ part.content if part.content.is_a?(Lutaml::Model::Serializable)
86
+ end
87
+ end
88
+
89
+ def note_containers(document)
90
+ (document.footnotes&.footnote_entries || []) +
91
+ (document.endnotes&.endnote_entries || [])
92
+ end
93
+
94
+ def comment_containers(document)
95
+ document.comments&.comments || []
96
+ end
97
+
98
+ # Walk a container's paragraphs, recursing into (nested) tables.
99
+ def replace_in_container(container)
100
+ return unless container
101
+
102
+ replace_in_paragraphs(container)
103
+ (container.tables || []).each { |table| replace_in_table(table) }
104
+ end
105
+
106
+ def replace_in_table(table)
107
+ (table.rows || []).each do |row|
108
+ (row.cells || []).each { |cell| replace_in_container(cell) }
109
+ end
110
+ end
111
+
112
+ # Walk only paragraphs — for note/comment/header/footer parts,
113
+ # whose models do not expose nested tables.
114
+ def replace_in_paragraphs(container)
115
+ (container.paragraphs || []).each do |paragraph|
116
+ replace_in_paragraph(paragraph)
117
+ end
118
+ end
119
+
120
+ def replace_in_paragraph(paragraph)
121
+ replace_in_runs(paragraph.runs)
122
+ (paragraph.hyperlinks || []).each do |hyperlink|
123
+ replace_in_runs(hyperlink.runs)
124
+ end
125
+ end
126
+
127
+ def replace_in_runs(runs)
128
+ (runs || []).each do |run|
129
+ replace_in_run_properties(run.properties)
130
+ end
131
+ end
132
+
133
+ # Rewrite matching RunFonts attribute values in place.
134
+ def replace_in_run_properties(run_properties)
135
+ fonts = run_properties&.fonts
136
+ return unless fonts
137
+
138
+ FONT_ATTRIBUTES.each do |attr|
139
+ next unless fonts.method(attr).call == @from
140
+
141
+ fonts.method(:"#{attr}=").call(@to)
142
+ @count += 1
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -32,6 +32,10 @@ module Uniword
32
32
  end
33
33
 
34
34
  missing = footnote_entries.size - counts["footnote"]
35
+ return unless missing.positive?
36
+
37
+ # element_order from the parser may be frozen; never mutate it.
38
+ self.element_order = element_order.dup
35
39
  missing.times do
36
40
  element_order << Lutaml::Xml::Element.new("Element", "footnote")
37
41
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require_relative "../vml_office"
5
4
 
6
5
  module Uniword
7
6
  module Wordprocessingml
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require "uniword/ooxml/types/relationship_id"
5
4
 
6
5
  module Uniword
7
6
  module Wordprocessingml
@@ -17,20 +17,6 @@ module Uniword
17
17
  end
18
18
  end
19
19
 
20
- # Tabs container for numbering level
21
- #
22
- # Element: <w:tabs>
23
- class Tabs < Lutaml::Model::Serializable
24
- attribute :tab, Tab, collection: true
25
-
26
- xml do
27
- element "tabs"
28
- namespace Uniword::Ooxml::Namespaces::WordProcessingML
29
- mixed_content
30
- map_element "tab", to: :tab, render_nil: false
31
- end
32
- end
33
-
34
20
  # Numbering level suffix type
35
21
  #
36
22
  # Element: <w:suff>
@@ -63,6 +49,10 @@ module Uniword
63
49
  #
64
50
  # Generated from OOXML schema: wordprocessingml.yml
65
51
  # Element: <w:lvl>
52
+ #
53
+ # Per ECMA-376 CT_Lvl, `w:ind` and `w:tabs` are NOT direct children of
54
+ # `w:lvl` — they belong to the level's `w:pPr`. They are therefore not
55
+ # mapped here; use `pPr.indentation` / `pPr.tabs`.
66
56
  class Level < Lutaml::Model::Serializable
67
57
  attribute :ilvl, :integer
68
58
  attribute :tentative, :string
@@ -75,8 +65,6 @@ module Uniword
75
65
  attribute :lvlText, LvlText
76
66
  attribute :lvlJc, LvlJc
77
67
  attribute :pPr, ParagraphProperties
78
- attribute :tabs, Tabs
79
- attribute :ind, Ind
80
68
  attribute :rPr, RunProperties
81
69
 
82
70
  xml do
@@ -95,8 +83,6 @@ module Uniword
95
83
  map_element "lvlText", to: :lvlText, render_nil: false
96
84
  map_element "lvlJc", to: :lvlJc, render_nil: false
97
85
  map_element "pPr", to: :pPr, render_nil: false
98
- map_element "tabs", to: :tabs, render_nil: false
99
- map_element "ind", to: :ind, render_nil: false
100
86
  map_element "rPr", to: :rPr, render_nil: false
101
87
  end
102
88
 
@@ -122,12 +108,12 @@ module Uniword
122
108
 
123
109
  # Get left indent (convenience method - returns integer)
124
110
  def left_indent_value
125
- ind&.left&.to_i
111
+ pPr&.indentation&.left.to_i
126
112
  end
127
113
 
128
114
  # Get hanging indent (convenience method - returns integer)
129
115
  def hanging_indent_value
130
- ind&.hanging&.to_i
116
+ pPr&.indentation&.hanging.to_i
131
117
  end
132
118
 
133
119
  # Get level text value (convenience method - returns string)
@@ -1,17 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require_relative "math_font"
5
- require_relative "brk_bin"
6
- require_relative "brk_bin_sub"
7
- require_relative "small_frac"
8
- require_relative "disp_def"
9
- require_relative "l_margin"
10
- require_relative "r_margin"
11
- require_relative "def_jc"
12
- require_relative "wrap_indent"
13
- require_relative "int_lim"
14
- require_relative "nary_lim"
15
4
 
16
5
  module Uniword
17
6
  module Wordprocessingml
@@ -80,8 +80,10 @@ module Uniword
80
80
  start: Uniword::Wordprocessingml::Start.new(val: 1),
81
81
  numFmt: Uniword::Wordprocessingml::NumFmt.new(val: "decimal"),
82
82
  lvlJc: Uniword::Wordprocessingml::LvlJc.new(val: "left"),
83
- ind: Uniword::Wordprocessingml::Ind.new(
84
- left: (720 * (i + 1)).to_s, hanging: "360",
83
+ pPr: Uniword::Wordprocessingml::ParagraphProperties.new(
84
+ indentation: Uniword::Properties::Indentation.new(
85
+ left: 720 * (i + 1), hanging: 360,
86
+ ),
85
87
  ),
86
88
  )
87
89
  end
@@ -104,8 +106,10 @@ module Uniword
104
106
  numFmt: Uniword::Wordprocessingml::NumFmt.new(val: "bullet"),
105
107
  lvlText: Uniword::Wordprocessingml::LvlText.new(val: level_char),
106
108
  lvlJc: Uniword::Wordprocessingml::LvlJc.new(val: "left"),
107
- ind: Uniword::Wordprocessingml::Ind.new(
108
- left: (720 * (i + 1)).to_s, hanging: "360",
109
+ pPr: Uniword::Wordprocessingml::ParagraphProperties.new(
110
+ indentation: Uniword::Properties::Indentation.new(
111
+ left: 720 * (i + 1), hanging: 360,
112
+ ),
109
113
  ),
110
114
  rPr: Uniword::Wordprocessingml::RunProperties.new(
111
115
  fonts: Properties::RunFonts.new(ascii: "Symbol",
@@ -126,8 +130,10 @@ module Uniword
126
130
  start: Uniword::Wordprocessingml::Start.new(val: 1),
127
131
  numFmt: Uniword::Wordprocessingml::NumFmt.new(val: format),
128
132
  lvlJc: Uniword::Wordprocessingml::LvlJc.new(val: "left"),
129
- ind: Uniword::Wordprocessingml::Ind.new(
130
- left: (720 * (i + 1)).to_s, hanging: "360",
133
+ pPr: Uniword::Wordprocessingml::ParagraphProperties.new(
134
+ indentation: Uniword::Properties::Indentation.new(
135
+ left: 720 * (i + 1), hanging: 360,
136
+ ),
131
137
  ),
132
138
  )
133
139
  end
@@ -144,8 +150,10 @@ module Uniword
144
150
  start: Uniword::Wordprocessingml::Start.new(val: 1),
145
151
  numFmt: Uniword::Wordprocessingml::NumFmt.new(val: format),
146
152
  lvlJc: Uniword::Wordprocessingml::LvlJc.new(val: "left"),
147
- ind: Uniword::Wordprocessingml::Ind.new(
148
- left: (720 * (i + 1)).to_s, hanging: "360",
153
+ pPr: Uniword::Wordprocessingml::ParagraphProperties.new(
154
+ indentation: Uniword::Properties::Indentation.new(
155
+ left: 720 * (i + 1), hanging: 360,
156
+ ),
149
157
  ),
150
158
  )
151
159
  end