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
@@ -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
@@ -1,128 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "zip"
4
- # LayerValidator autoloaded via lib/uniword/validation.rb
5
-
6
- module Uniword
7
- module Validation
8
- module Validators
9
- # Validates OOXML part structure (required/optional parts).
10
- #
11
- # Responsibility: Validate OOXML parts exist per ISO/IEC 29500.
12
- # Single Responsibility: Only validates part existence.
13
- #
14
- # This is Layer 3 validation - validates that all required OOXML
15
- # parts are present in the document package.
16
- #
17
- # Checks:
18
- # - Required parts exist (Content Types, document, relationships)
19
- # - Optional but common parts (styles, numbering)
20
- #
21
- # @example Validate OOXML parts
22
- # validator = OoxmlPartValidator.new(config)
23
- # result = validator.validate('/path/to/document.docx')
24
- # puts result.valid? # => true
25
- class OoxmlPartValidator < LayerValidator
26
- # Required parts per ISO/IEC 29500
27
- REQUIRED_PARTS = {
28
- "[Content_Types].xml" => "Content Types definition",
29
- "word/document.xml" => "Main document content",
30
- "_rels/.rels" => "Package relationships",
31
- }.freeze
32
-
33
- # Optional but commonly used parts
34
- OPTIONAL_PARTS = {
35
- "word/styles.xml" => "Styles definition",
36
- "word/numbering.xml" => "Numbering definition",
37
- "word/_rels/document.xml.rels" => "Document relationships",
38
- "word/settings.xml" => "Document settings",
39
- "word/fontTable.xml" => "Font table",
40
- "word/theme/theme1.xml" => "Theme definition",
41
- }.freeze
42
-
43
- def layer_name
44
- "OOXML Parts"
45
- end
46
-
47
- def validate(path)
48
- result = LayerValidationResult.new(layer_name)
49
-
50
- Zip::File.open(path) do |zip|
51
- validate_required_parts(zip, result)
52
- validate_optional_parts(zip, result) if warn_missing_optional?
53
- end
54
-
55
- result
56
- rescue Zip::Error => e
57
- result.add_error(
58
- "Cannot open ZIP file: #{e.message}",
59
- critical: true,
60
- )
61
- result
62
- end
63
-
64
- private
65
-
66
- def validate_required_parts(zip, result)
67
- return unless require_content_types? ||
68
- require_document? ||
69
- require_relationships?
70
-
71
- REQUIRED_PARTS.each do |part_name, description|
72
- next unless should_check_part?(part_name)
73
-
74
- next if zip.find_entry(part_name)
75
-
76
- result.add_error(
77
- "Missing #{description}: #{part_name}",
78
- critical: true,
79
- )
80
- end
81
- end
82
-
83
- def validate_optional_parts(zip, result)
84
- OPTIONAL_PARTS.each do |part_name, description|
85
- next if zip.find_entry(part_name)
86
-
87
- result.add_info(
88
- "Missing #{description}: #{part_name} (optional)",
89
- )
90
- end
91
- end
92
-
93
- def should_check_part?(part_name)
94
- case part_name
95
- when "[Content_Types].xml"
96
- require_content_types?
97
- when "word/document.xml"
98
- require_document?
99
- when "_rels/.rels"
100
- require_relationships?
101
- else
102
- true
103
- end
104
- end
105
-
106
- def require_content_types?
107
- layer_config[:require_content_types] != false
108
- end
109
-
110
- def require_document?
111
- layer_config[:require_document] != false
112
- end
113
-
114
- def require_relationships?
115
- layer_config[:require_relationships] != false
116
- end
117
-
118
- def warn_missing_optional?
119
- layer_config[:warn_missing_optional] != false
120
- end
121
-
122
- def layer_config
123
- @config[:ooxml_parts] || {}
124
- end
125
- end
126
- end
127
- end
128
- end
@@ -1,147 +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 relationship integrity (targets exist, no broken refs).
11
- #
12
- # Responsibility: Validate relationship files and targets.
13
- # Single Responsibility: Only validates relationships.
14
- #
15
- # This is Layer 5 validation - validates that all relationship
16
- # files are valid and their targets exist.
17
- #
18
- # Checks:
19
- # - Relationship files are well-formed
20
- # - Relationship targets exist
21
- # - Relationship types are valid
22
- #
23
- # @example Validate relationships
24
- # validator = RelationshipValidator.new(config)
25
- # result = validator.validate('/path/to/document.docx')
26
- # puts result.valid? # => true
27
- class RelationshipValidator < LayerValidator
28
- RELATIONSHIP_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships"
29
-
30
- def layer_name
31
- "Relationships"
32
- end
33
-
34
- def validate(path)
35
- result = LayerValidationResult.new(layer_name)
36
-
37
- Zip::File.open(path) do |zip|
38
- validate_relationships(zip, result)
39
- end
40
-
41
- result
42
- rescue Zip::Error => e
43
- result.add_error(
44
- "Cannot open ZIP file: #{e.message}",
45
- critical: true,
46
- )
47
- result
48
- end
49
-
50
- private
51
-
52
- def validate_relationships(zip, result)
53
- rel_entries = zip.entries.select { |e| e.name.end_with?(".rels") }
54
-
55
- if rel_entries.empty?
56
- result.add_warning("No relationship files found")
57
- return
58
- end
59
-
60
- rel_entries.each do |entry|
61
- validate_relationship_file(zip, entry, result)
62
- end
63
- end
64
-
65
- def validate_relationship_file(zip, entry, result)
66
- xml_content = entry.get_input_stream.read
67
- doc = Nokogiri::XML(xml_content)
68
-
69
- # Get the base directory for this .rels file
70
- base_dir = File.dirname(entry.name.sub("/_rels/", "/"))
71
- base_dir = "" if base_dir == "."
72
-
73
- doc.xpath("//xmlns:Relationship",
74
- "xmlns" => RELATIONSHIP_NAMESPACE).each do |rel|
75
- validate_relationship(zip, rel, base_dir, result)
76
- end
77
- rescue Nokogiri::XML::SyntaxError => e
78
- result.add_error(
79
- "Malformed relationship file #{entry.name}: #{e.message}",
80
- )
81
- rescue StandardError => e
82
- result.add_error(
83
- "Failed to validate #{entry.name}: #{e.message}",
84
- )
85
- end
86
-
87
- def validate_relationship(zip, rel_node, base_dir, result)
88
- target = rel_node["Target"]
89
- type = rel_node["Type"]
90
- target_mode = rel_node["TargetMode"]
91
-
92
- # Skip external relationships
93
- return if target_mode == "External"
94
-
95
- # Check if target exists
96
- if check_targets_exist? && target && !target.start_with?("#")
97
- target_path = resolve_target_path(base_dir, target)
98
-
99
- unless zip.find_entry(target_path)
100
- result.add_error(
101
- "Relationship target not found: #{target_path}",
102
- )
103
- end
104
- end
105
-
106
- # Validate relationship type
107
- return unless validate_types? && type
108
-
109
- validate_relationship_type(type, result)
110
- end
111
-
112
- def resolve_target_path(base_dir, target)
113
- # Handle absolute paths (starting with /)
114
- return target[1..] if target.start_with?("/")
115
-
116
- # Handle relative paths
117
- if base_dir.empty?
118
- target
119
- else
120
- File.join(base_dir, target)
121
- end
122
- end
123
-
124
- def validate_relationship_type(type, result)
125
- # Just validate it's a URI-like string
126
- return if type.include?("/")
127
-
128
- result.add_warning(
129
- "Unusual relationship type: #{type}",
130
- )
131
- end
132
-
133
- def check_targets_exist?
134
- layer_config[:check_targets_exist] != false
135
- end
136
-
137
- def validate_types?
138
- layer_config[:validate_types] != false
139
- end
140
-
141
- def layer_config
142
- @config[:relationships] || {}
143
- end
144
- end
145
- end
146
- end
147
- end