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
@@ -1,272 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- # All Validation classes autoloaded via lib/uniword/validation.rb and validators.rb
5
- # Configuration::ConfigurationLoader autoloaded via lib/uniword.rb
6
-
7
- module Uniword
8
- module Validation
9
- # Main document validator orchestrator.
10
- #
11
- # Responsibility: Orchestrate 7-layer document validation.
12
- # Single Responsibility: Only coordinates validation layers.
13
- #
14
- # Validates Word documents at 7 layers (MECE):
15
- # 1. File Structure - file exists, readable, valid extension
16
- # 2. ZIP Integrity - valid archive, no corruption
17
- # 3. OOXML Parts - required parts present
18
- # 4. XML Schema - well-formed XML
19
- # 5. Relationships - valid references
20
- # 6. Content Types - consistent type declarations
21
- # 7. Document Semantics - valid document structure
22
- #
23
- # External configuration: config/validation_rules.yml
24
- #
25
- # @example Basic validation
26
- # validator = DocumentValidator.new
27
- # report = validator.validate('document.docx')
28
- # puts report.valid? # => true
29
- #
30
- # @example Custom configuration
31
- # validator = DocumentValidator.new(
32
- # config_file: 'custom_validation.yml'
33
- # )
34
- # report = validator.validate('document.docx')
35
- class DocumentValidator
36
- # @return [Hash] Configuration
37
- attr_reader :config
38
-
39
- # @return [Array<LayerValidator>] Validators
40
- attr_reader :validators
41
-
42
- # Initialize document validator.
43
- #
44
- # @param config_file [String] Path to configuration file
45
- #
46
- # @example Create with default config
47
- # validator = DocumentValidator.new
48
- #
49
- # @example Create with custom config
50
- # validator = DocumentValidator.new(
51
- # config_file: 'config/custom_validation.yml'
52
- # )
53
- def initialize(config_file: nil)
54
- @config = load_config(config_file)
55
- @validators = initialize_validators
56
- end
57
-
58
- # Validate a document file.
59
- #
60
- # Runs all enabled validators in sequence. Can fail-fast on
61
- # critical errors if configured.
62
- #
63
- # @param path [String] Path to .docx file
64
- # @return [DocumentValidationReport] Validation results
65
- #
66
- # @example Validate a document
67
- # report = validator.validate('document.docx')
68
- # if report.valid?
69
- # puts "Document is valid!"
70
- # else
71
- # puts "Errors: #{report.errors.count}"
72
- # end
73
- def validate(path)
74
- report = DocumentValidationReport.new(path)
75
-
76
- @validators.each do |validator|
77
- next unless validator.enabled?
78
-
79
- result = validator.validate(path)
80
- report.add_layer_result(validator.layer_name, result)
81
-
82
- # Fail fast if critical failure and configured to do so
83
- break if result.critical_failures? && fail_fast?
84
- end
85
-
86
- report
87
- end
88
-
89
- # Quick validity check (boolean).
90
- #
91
- # @param path [String] Path to .docx file
92
- # @return [Boolean] true if document is valid
93
- #
94
- # @example Quick check
95
- # if validator.valid?('document.docx')
96
- # # Process document
97
- # end
98
- def valid?(path)
99
- validate(path).valid?
100
- end
101
-
102
- private
103
-
104
- def load_config(config_file)
105
- if config_file
106
- Configuration::ConfigurationLoader.load_file(config_file)
107
- else
108
- # Try to load default config
109
- default_path = File.join(
110
- Configuration::ConfigurationLoader::CONFIG_DIR,
111
- "validation_rules.yml",
112
- )
113
-
114
- if File.exist?(default_path)
115
- Configuration::ConfigurationLoader.load("validation_rules")
116
- else
117
- # Use empty config with defaults
118
- { document_validation: { fail_fast: true } }
119
- end
120
- end
121
- rescue Configuration::ConfigurationError
122
- # If config fails to load, use defaults
123
- { document_validation: { fail_fast: true } }
124
- end
125
-
126
- def initialize_validators
127
- config_hash = @config[:document_validation] || @config
128
-
129
- [
130
- Validators::FileStructureValidator.new(config_hash),
131
- Validators::ZipIntegrityValidator.new(config_hash),
132
- Validators::OoxmlPartValidator.new(config_hash),
133
- Validators::XmlSchemaValidator.new(config_hash),
134
- Validators::RelationshipValidator.new(config_hash),
135
- Validators::ContentTypeValidator.new(config_hash),
136
- Validators::DocumentSemanticsValidator.new(config_hash),
137
- ]
138
- end
139
-
140
- def fail_fast?
141
- validation_config = @config[:document_validation] || {}
142
- validation_config[:fail_fast] != false
143
- end
144
- end
145
-
146
- # Validation report for document validation.
147
- #
148
- # Aggregates results from all validation layers.
149
- class DocumentValidationReport
150
- # @return [String] Path to validated file
151
- attr_reader :file_path
152
-
153
- # @return [Hash<String, LayerValidationResult>] Results by layer
154
- attr_reader :layer_results
155
-
156
- # Initialize a new report.
157
- #
158
- # @param file_path [String] Path to validated file
159
- def initialize(file_path)
160
- @file_path = file_path
161
- @layer_results = {}
162
- end
163
-
164
- # Add a layer validation result.
165
- #
166
- # @param layer_name [String] Name of validation layer
167
- # @param result [LayerValidationResult] Validation result
168
- # @return [void]
169
- def add_layer_result(layer_name, result)
170
- @layer_results[layer_name] = result
171
- end
172
-
173
- # Check if document is valid overall.
174
- #
175
- # @return [Boolean] true if all layers passed
176
- def valid?
177
- @layer_results.values.all?(&:valid?)
178
- end
179
-
180
- # Get all errors from all layers.
181
- #
182
- # @return [Array<Hash>] All errors with layer info
183
- def errors
184
- @layer_results.flat_map do |layer, result|
185
- result.errors.map { |err| err.merge(layer: layer) }
186
- end
187
- end
188
-
189
- # Get all warnings from all layers.
190
- #
191
- # @return [Array<Hash>] All warnings with layer info
192
- def warnings
193
- @layer_results.flat_map do |layer, result|
194
- result.warnings.map { |warn| warn.merge(layer: layer) }
195
- end
196
- end
197
-
198
- # Get all info messages from all layers.
199
- #
200
- # @return [Array<Hash>] All info messages with layer info
201
- def infos
202
- @layer_results.flat_map do |layer, result|
203
- result.infos.map { |info| info.merge(layer: layer) }
204
- end
205
- end
206
-
207
- # Check if any layer has critical failures.
208
- #
209
- # @return [Boolean] true if critical failures exist
210
- def critical_failures?
211
- @layer_results.values.any?(&:critical_failures?)
212
- end
213
-
214
- # Get summary statistics.
215
- #
216
- # @return [Hash] Summary data
217
- def summary
218
- {
219
- file: @file_path,
220
- valid: valid?,
221
- layers_validated: @layer_results.count,
222
- errors: errors.count,
223
- warnings: warnings.count,
224
- infos: infos.count,
225
- critical: critical_failures?,
226
- }
227
- end
228
-
229
- # Export to JSON string.
230
- #
231
- # @return [String] JSON representation
232
- def to_json(*_args)
233
- JSON.pretty_generate(
234
- summary: summary,
235
- layers: @layer_results.transform_values(&:to_h),
236
- )
237
- end
238
-
239
- # Convert to string for display.
240
- #
241
- # @return [String] String representation
242
- def to_s
243
- lines = [
244
- "Document Validation Report: #{@file_path}",
245
- "=" * 60,
246
- "",
247
- ]
248
-
249
- @layer_results.each_value do |result|
250
- lines << result.to_s
251
- end
252
-
253
- lines << ""
254
- lines << "Summary:"
255
- lines << " Valid: #{valid?}"
256
- lines << " Errors: #{errors.count}"
257
- lines << " Warnings: #{warnings.count}"
258
- lines << " Info: #{infos.count}"
259
-
260
- if errors.any?
261
- lines << ""
262
- lines << "Errors:"
263
- errors.each do |error|
264
- lines << " [#{error[:layer]}] #{error[:message]}"
265
- end
266
- end
267
-
268
- lines.join("\n")
269
- end
270
- end
271
- end
272
- end
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Uniword
4
- module Validation
5
- # Validates in-memory DocumentRoot structure.
6
- #
7
- # Performs structural checks on the document model without requiring
8
- # file access. Complements DocumentValidator (file-level 7-layer
9
- # pipeline) for quick in-process validation.
10
- #
11
- # Follows Open/Closed: add new check methods and register them in
12
- # CHECKS without modifying existing code.
13
- #
14
- # @example
15
- # validator = StructuralValidator.new(document)
16
- # validator.valid? # => true/false
17
- # validator.errors # => ["bookmarkStart id='42' has no bookmarkEnd"]
18
- # validator.warnings # => ["Empty paragraph at index 7"]
19
- class StructuralValidator
20
- # Registered check methods. Each returns an array of
21
- # { severity:, message: } hashes.
22
- CHECKS = %i[
23
- check_body_present
24
- check_bookmark_pairing
25
- check_bookmark_uniqueness
26
- check_empty_paragraphs
27
- ].freeze
28
-
29
- attr_reader :document
30
-
31
- def initialize(document)
32
- @document = document
33
- end
34
-
35
- # @return [Boolean] true if no errors (warnings are non-fatal)
36
- def valid?
37
- errors.empty?
38
- end
39
-
40
- # @return [Array<String>] Error messages (severity: error)
41
- def errors
42
- @errors ||= issues
43
- .select { |i| i[:severity] == :error }
44
- .map { |i| i[:message] }
45
- end
46
-
47
- # @return [Array<String>] Warning messages (severity: warning)
48
- def warnings
49
- @warnings ||= issues
50
- .select { |i| i[:severity] == :warning }
51
- .map { |i| i[:message] }
52
- end
53
-
54
- # @return [Array<Hash>] All issues
55
- def issues
56
- @issues ||= CHECKS.flat_map { |check| send(check) }
57
- end
58
-
59
- private
60
-
61
- def check_body_present
62
- return [] if document.body
63
-
64
- [{ severity: :error,
65
- message: "Document body is missing" }]
66
- end
67
-
68
- def check_bookmark_pairing
69
- return [] unless document.body
70
-
71
- starts = document.body.bookmark_starts || []
72
- ends = document.body.bookmark_ends || []
73
- end_ids = ends.filter_map(&:id).to_set
74
-
75
- starts.filter_map do |bs|
76
- next if end_ids.include?(bs.id)
77
-
78
- { severity: :error,
79
- message: "bookmarkStart id='#{bs.id}' (name='#{bs.name}') " \
80
- "has no matching bookmarkEnd" }
81
- end
82
- end
83
-
84
- def check_bookmark_uniqueness
85
- return [] unless document.body
86
-
87
- starts = document.body.bookmark_starts || []
88
- seen = {}
89
- starts.filter_map do |bs|
90
- next unless bs.name
91
- next if bs.name.to_s == "_GoBack" # Word internal bookmark
92
-
93
- if seen[bs.name.to_s]
94
- { severity: :warning,
95
- message: "Duplicate bookmark name '#{bs.name}'" }
96
- else
97
- seen[bs.name.to_s] = true
98
- nil
99
- end
100
- end
101
- end
102
-
103
- def check_empty_paragraphs
104
- return [] unless document.body
105
-
106
- paragraphs = document.body.paragraphs || []
107
- paragraphs.each_with_index.filter_map do |para, idx|
108
- next unless para.runs.nil? || para.runs.empty?
109
-
110
- { severity: :warning,
111
- message: "Empty paragraph at index #{idx}" }
112
- end
113
- end
114
- end
115
- end
116
- end
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "zip"
4
- require "nokogiri"
5
- # LayerValidator autoloaded via lib/uniword/validation.rb
6
-
7
- module Uniword
8
- module Validation
9
- module Validators
10
- # Validates content type consistency.
11
- #
12
- # Responsibility: Validate [Content_Types].xml declarations.
13
- # Single Responsibility: Only validates content type definitions.
14
- #
15
- # This is Layer 6 validation - validates that content types are
16
- # properly declared and consistent with actual file extensions.
17
- #
18
- # Checks:
19
- # - [Content_Types].xml is well-formed
20
- # - Extensions and overrides are consistent
21
- # - Required content types are declared
22
- #
23
- # @example Validate content types
24
- # validator = ContentTypeValidator.new(config)
25
- # result = validator.validate('/path/to/document.docx')
26
- # puts result.valid? # => true
27
- class ContentTypeValidator < LayerValidator
28
- CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
29
-
30
- REQUIRED_CONTENT_TYPES = [
31
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
32
- "application/vnd.openxmlformats-package.relationships+xml",
33
- ].freeze
34
-
35
- def layer_name
36
- "Content Types"
37
- end
38
-
39
- def validate(path)
40
- result = LayerValidationResult.new(layer_name)
41
-
42
- Zip::File.open(path) do |zip|
43
- validate_content_types(zip, result)
44
- end
45
-
46
- result
47
- rescue Zip::Error => e
48
- result.add_error(
49
- "Cannot open ZIP file: #{e.message}",
50
- critical: true,
51
- )
52
- result
53
- end
54
-
55
- private
56
-
57
- def validate_content_types(zip, result)
58
- entry = zip.find_entry("[Content_Types].xml")
59
-
60
- unless entry
61
- result.add_error(
62
- "Missing [Content_Types].xml",
63
- critical: true,
64
- )
65
- return
66
- end
67
-
68
- validate_content_types_file(zip, entry, result)
69
- end
70
-
71
- def validate_content_types_file(zip, entry, result)
72
- xml_content = entry.get_input_stream.read
73
- doc = Nokogiri::XML(xml_content)
74
-
75
- # Check for required content types
76
- validate_required_types(doc, result) if check_consistency?
77
-
78
- # Validate extensions match actual files
79
- if validate_extensions?
80
- validate_extension_consistency(zip, doc,
81
- result)
82
- end
83
- rescue Nokogiri::XML::SyntaxError => e
84
- result.add_error(
85
- "Malformed [Content_Types].xml: #{e.message}",
86
- critical: true,
87
- )
88
- rescue StandardError => e
89
- result.add_error(
90
- "Failed to validate content types: #{e.message}",
91
- )
92
- end
93
-
94
- def validate_required_types(doc, result)
95
- declared_types = extract_declared_types(doc)
96
-
97
- REQUIRED_CONTENT_TYPES.each do |required_type|
98
- next if declared_types.include?(required_type)
99
-
100
- result.add_warning(
101
- "Required content type not declared: #{required_type}",
102
- )
103
- end
104
- end
105
-
106
- def extract_declared_types(doc)
107
- types = []
108
-
109
- # From Default elements
110
- doc.xpath("//xmlns:Default",
111
- "xmlns" => CONTENT_TYPES_NAMESPACE).each do |default|
112
- types << default["ContentType"] if default["ContentType"]
113
- end
114
-
115
- # From Override elements
116
- doc.xpath("//xmlns:Override",
117
- "xmlns" => CONTENT_TYPES_NAMESPACE).each do |override|
118
- types << override["ContentType"] if override["ContentType"]
119
- end
120
-
121
- types
122
- end
123
-
124
- def validate_extension_consistency(zip, doc, result)
125
- # Get declared extensions
126
- extensions = {}
127
- doc.xpath("//xmlns:Default",
128
- "xmlns" => CONTENT_TYPES_NAMESPACE).each do |default|
129
- ext = default["Extension"]
130
- content_type = default["ContentType"]
131
- extensions[ext] = content_type if ext && content_type
132
- end
133
-
134
- # Check if files with declared extensions exist
135
- zip.entries.each do |entry|
136
- next if entry.directory?
137
-
138
- ext = File.extname(entry.name)[1..] # Remove leading dot
139
- next unless ext
140
-
141
- if extensions.key?(ext)
142
- # Expected extension found
143
- else
144
- result.add_info(
145
- "File #{entry.name} has undeclared extension: .#{ext}",
146
- )
147
- end
148
- end
149
- end
150
-
151
- def check_consistency?
152
- layer_config[:check_consistency] != false
153
- end
154
-
155
- def validate_extensions?
156
- layer_config[:validate_extensions] != false
157
- end
158
-
159
- def layer_config
160
- @config[:content_types] || {}
161
- end
162
- end
163
- end
164
- end
165
- end
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # LayerValidator autoloaded via lib/uniword/validation.rb
4
-
5
- module Uniword
6
- module Validation
7
- module Validators
8
- # Validates file structure (existence, readability, extension, size).
9
- #
10
- # Responsibility: Validate file-level properties.
11
- # Single Responsibility: Only validates file structure.
12
- #
13
- # This is Layer 1 validation - the most basic check that must pass
14
- # before any other validation can occur.
15
- #
16
- # Checks:
17
- # - File exists
18
- # - File is readable
19
- # - File extension is appropriate
20
- # - File size is reasonable
21
- #
22
- # @example Validate a file
23
- # validator = FileStructureValidator.new(config)
24
- # result = validator.validate('/path/to/document.docx')
25
- # puts result.valid? # => true
26
- class FileStructureValidator < LayerValidator
27
- def layer_name
28
- "File Structure"
29
- end
30
-
31
- def validate(path)
32
- result = LayerValidationResult.new(layer_name)
33
-
34
- # Check file exists
35
- unless File.exist?(path)
36
- return result.add_error("File does not exist",
37
- critical: true)
38
- end
39
-
40
- # Check readable
41
- unless File.readable?(path)
42
- return result.add_error("File is not readable",
43
- critical: true)
44
- end
45
-
46
- # Check extension
47
- validate_extension(path, result) if check_extension?
48
-
49
- # Check file size
50
- validate_size(path, result) if check_size?
51
-
52
- result
53
- end
54
-
55
- private
56
-
57
- def check_extension?
58
- layer_config[:check_extension] != false
59
- end
60
-
61
- def check_size?
62
- layer_config[:check_size] != false
63
- end
64
-
65
- def layer_config
66
- @config[:file_structure] || {}
67
- end
68
-
69
- def validate_extension(path, result)
70
- allowed = layer_config[:allowed_extensions] || [".docx", ".doc"]
71
-
72
- return if allowed.any? { |ext| path.end_with?(ext) }
73
-
74
- result.add_warning(
75
- "Unusual file extension (expected #{allowed.join(' or ')})",
76
- )
77
- end
78
-
79
- def validate_size(path, result)
80
- size = File.size(path)
81
- max_size = @config[:max_file_size] || 104_857_600 # 100MB default
82
-
83
- if size.zero?
84
- result.add_error("File is empty", critical: true)
85
- elsif size > max_size
86
- result.add_warning(
87
- "File is very large (#{format_bytes(size)})",
88
- )
89
- end
90
- end
91
-
92
- def format_bytes(bytes)
93
- if bytes < 1024
94
- "#{bytes} bytes"
95
- elsif bytes < 1024 * 1024
96
- "#{(bytes / 1024.0).round(1)} KB"
97
- else
98
- "#{(bytes / (1024.0 * 1024)).round(1)} MB"
99
- end
100
- end
101
- end
102
- end
103
- end
104
- end