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
@@ -11,8 +11,8 @@ module Uniword
11
11
  class SharedStringTable < Lutaml::Model::Serializable
12
12
  attribute :count, :integer
13
13
  attribute :unique_count, :integer
14
- attribute :si_entries, :stringItem, collection: true,
15
- initialize_empty: true
14
+ attribute :si_entries, StringItem, collection: true,
15
+ initialize_empty: true
16
16
 
17
17
  xml do
18
18
  element "sst"
@@ -14,8 +14,8 @@ module Uniword
14
14
  xml do
15
15
  element "tableFormula"
16
16
  namespace Uniword::Ooxml::Namespaces::SpreadsheetML
17
- mixed_content
18
17
 
18
+ map_content to: :content
19
19
  map_attribute "array", to: :array, render_nil: false
20
20
  end
21
21
  end
@@ -101,22 +101,55 @@ module Uniword
101
101
 
102
102
  # Navigate to a property on an object
103
103
  #
104
+ # Dispatches on the object's class: Hash keys, declared attributes
105
+ # of Lutaml::Model objects, or public zero-arity readers on plain
106
+ # Ruby objects.
107
+ #
104
108
  # @param object [Object] Object to navigate
105
109
  # @param property [String] Property name
106
110
  # @return [Object] Property value or nil
107
111
  def navigate_property(object, property)
108
- # For hashes, use key access
109
- if object.is_a?(Hash)
112
+ case object
113
+ when Hash
110
114
  object[property.to_sym] || object[property]
115
+ when Lutaml::Model::Serializable
116
+ read_model_attribute(object, property)
111
117
  else
112
- # public_send is the canonical Ruby idiom for template-driven
113
- # property access on arbitrary user objects. The property name
114
- # comes from template syntax and may reference any public
115
- # reader method on the model.
116
- object.public_send(property.to_sym)
118
+ read_object_property(object, property)
117
119
  end
118
120
  end
119
121
 
122
+ # Read a declared attribute from a Lutaml::Model object
123
+ #
124
+ # Only attributes declared in the model's schema are readable from
125
+ # templates; arbitrary public methods are not exposed. The schema
126
+ # acts as the allowlist, the model's public method table provides
127
+ # the value.
128
+ #
129
+ # @param object [Lutaml::Model::Serializable] Model instance
130
+ # @param property [String] Property name
131
+ # @return [Object] Attribute value or nil when not declared
132
+ def read_model_attribute(object, property)
133
+ name = property.to_sym
134
+ return unless object.class.attributes.key?(name)
135
+
136
+ object.method(name).call
137
+ end
138
+
139
+ # Read a property from a plain Ruby object
140
+ #
141
+ # Looks the property up in the object's public method table.
142
+ # Returns nil when the object has no such public method.
143
+ #
144
+ # @param object [Object] Object to read from
145
+ # @param property [String] Property name
146
+ # @return [Object] Property value or nil
147
+ def read_object_property(object, property)
148
+ object.method(property.to_sym).call
149
+ rescue NameError
150
+ nil
151
+ end
152
+
120
153
  # Parse literal value from string
121
154
  #
122
155
  # @param value [String] Literal value string
@@ -52,34 +52,10 @@ module Uniword
52
52
  )
53
53
  end
54
54
 
55
- private
56
-
57
- OFFICE_THEME_PATH = File.expand_path(
58
- "../../../data/themes/office_theme.xml", __dir__
59
- ).freeze
60
-
61
- def default_format_scheme
62
- @default_format_scheme ||= begin
63
- theme = Drawingml::Theme.from_xml(File.read(OFFICE_THEME_PATH))
64
- theme.theme_elements.fmt_scheme
65
- end
66
- end
67
-
68
- # Build OOXML ThemeElements from friendly theme
55
+ # Build OOXML ColorScheme from friendly color scheme.
69
56
  #
70
- # @param friendly [Themes::Theme] Friendly theme
71
- # @return [Drawingml::ThemeElements] OOXML theme elements
72
- def build_theme_elements(friendly)
73
- return nil unless friendly
74
-
75
- Drawingml::ThemeElements.new(
76
- clr_scheme: build_color_scheme(friendly.color_scheme),
77
- font_scheme: build_font_scheme(friendly.font_scheme),
78
- fmt_scheme: default_format_scheme,
79
- )
80
- end
81
-
82
- # Build OOXML ColorScheme from friendly color scheme
57
+ # Public converter, used by partial theme edits (Design → Colors
58
+ # style scheme swaps that keep fonts and formats untouched).
83
59
  #
84
60
  # @param friendly_colors [Themes::ColorScheme, nil] Friendly color scheme
85
61
  # @return [Drawingml::ColorScheme, nil] OOXML color scheme
@@ -103,6 +79,58 @@ module Uniword
103
79
  )
104
80
  end
105
81
 
82
+ # Build OOXML FontScheme from friendly font scheme.
83
+ #
84
+ # Public converter, used by partial theme edits (Design → Fonts
85
+ # style scheme swaps that keep colors and formats untouched).
86
+ #
87
+ # @param friendly_fonts [Themes::FontScheme, nil] Friendly font scheme
88
+ # @return [Drawingml::FontScheme, nil] OOXML font scheme
89
+ def build_font_scheme(friendly_fonts)
90
+ return nil unless friendly_fonts
91
+
92
+ Drawingml::FontScheme.new(
93
+ name: friendly_fonts.name,
94
+ major_font_obj: build_major_font(friendly_fonts),
95
+ minor_font_obj: build_minor_font(friendly_fonts),
96
+ )
97
+ end
98
+
99
+ # A fresh parse of the bundled default Office theme.
100
+ #
101
+ # Each call re-parses the bundled XML so every returned theme (and
102
+ # its elements) is independent — callers may mutate it freely.
103
+ #
104
+ # @return [Drawingml::Theme] Default Office theme
105
+ def default_office_theme
106
+ Drawingml::Theme.from_xml(File.read(OFFICE_THEME_PATH))
107
+ end
108
+
109
+ private
110
+
111
+ OFFICE_THEME_PATH = File.expand_path(
112
+ "../../../data/themes/office_theme.xml", __dir__
113
+ ).freeze
114
+
115
+ def default_format_scheme
116
+ @default_format_scheme ||=
117
+ default_office_theme.theme_elements.fmt_scheme
118
+ end
119
+
120
+ # Build OOXML ThemeElements from friendly theme
121
+ #
122
+ # @param friendly [Themes::Theme] Friendly theme
123
+ # @return [Drawingml::ThemeElements] OOXML theme elements
124
+ def build_theme_elements(friendly)
125
+ return nil unless friendly
126
+
127
+ Drawingml::ThemeElements.new(
128
+ clr_scheme: build_color_scheme(friendly.color_scheme),
129
+ font_scheme: build_font_scheme(friendly.font_scheme),
130
+ fmt_scheme: default_format_scheme,
131
+ )
132
+ end
133
+
106
134
  # Build OOXML color reference from hex value
107
135
  #
108
136
  # Creates the appropriate color type (Dk1Color, Lt1Color, etc.)
@@ -118,20 +146,6 @@ module Uniword
118
146
  color_class.new.tap { |c| c.rgb = hex }
119
147
  end
120
148
 
121
- # Build OOXML FontScheme from friendly font scheme
122
- #
123
- # @param friendly_fonts [Themes::FontScheme, nil] Friendly font scheme
124
- # @return [Drawingml::FontScheme, nil] OOXML font scheme
125
- def build_font_scheme(friendly_fonts)
126
- return nil unless friendly_fonts
127
-
128
- Drawingml::FontScheme.new(
129
- name: friendly_fonts.name,
130
- major_font_obj: build_major_font(friendly_fonts),
131
- minor_font_obj: build_minor_font(friendly_fonts),
132
- )
133
- end
134
-
135
149
  # Build OOXML MajorFont from friendly font scheme
136
150
  #
137
151
  # @param friendly [Themes::FontScheme] Friendly font scheme
@@ -91,6 +91,8 @@ module Uniword
91
91
  # Add the SDT to element_order so it serializes correctly
92
92
  return unless body.element_order
93
93
 
94
+ # element_order from the parser may be frozen; never mutate it.
95
+ body.element_order = body.element_order.dup
94
96
  insert_element = Lutaml::Xml::Element.new("Element", "sdt")
95
97
  if position.positive? && body.element_order.size >= position
96
98
  body.element_order.insert(position, insert_element)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ # The single validation engine.
6
+ #
7
+ # Runs the rules registered in Rules::Registry against a validation
8
+ # context and returns Report::ValidationIssue results. One engine, two
9
+ # front-ends:
10
+ #
11
+ # - On-disk (post-save): Rules::DocumentContext wraps the DOCX package;
12
+ # driven by Validators::DocumentSemanticsValidator (verify layer 3).
13
+ # - In-memory (pre-save): Rules::ModelContext wraps the document model;
14
+ # driven by Wordprocessingml::DocumentRoot#valid? and CLI `validate`.
15
+ #
16
+ # Rules declare which context they consume via Rules::Base#context_type
17
+ # (:package or :model); only matching rules run.
18
+ module Engine
19
+ # Run every rule matching the context's type.
20
+ #
21
+ # @param context [Rules::DocumentContext, Rules::ModelContext]
22
+ # validation context; its #context_type selects the rule set
23
+ # @param rules [Array<Rules::Base>] candidate rules
24
+ # @return [Array<Report::ValidationIssue>] issues found
25
+ def self.run(context, rules: Rules::Registry.all)
26
+ rules.each_with_object([]) do |rule, issues|
27
+ next unless rule.context_type == context.context_type
28
+ next unless rule.applicable?(context)
29
+
30
+ issues.concat(rule.check(context))
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -19,7 +19,7 @@ module Uniword
19
19
  # @example Create a custom checker
20
20
  # class MyChecker < LinkChecker
21
21
  # def can_check?(link)
22
- # link.respond_to?(:custom_field)
22
+ # link.is_a?(Uniword::Wordprocessingml::Hyperlink)
23
23
  # end
24
24
  #
25
25
  # def check(link, document)
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "zip"
4
4
  require "nokogiri"
5
- require_relative "report/validation_issue"
6
5
 
7
6
  module Uniword
8
7
  module Validation
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require_relative "validation_issue"
5
4
 
6
5
  module Uniword
7
6
  module Validation
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rainbow"
4
- require_relative "verification_report"
5
4
 
6
5
  module Uniword
7
6
  module Validation
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "lutaml/model"
4
4
  require "time"
5
- require_relative "validation_issue"
6
- require_relative "layer_result"
7
5
 
8
6
  module Uniword
9
7
  module Validation
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ # Reporting value objects for validation and verification results.
6
+ #
7
+ # Registered here via autoload so any namespace can reference the
8
+ # report classes without load-order coupling.
9
+ module Report
10
+ autoload :ValidationIssue,
11
+ "uniword/validation/report/validation_issue"
12
+ autoload :LayerResult,
13
+ "uniword/validation/report/layer_result"
14
+ autoload :VerificationReport,
15
+ "uniword/validation/report/verification_report"
16
+ autoload :TerminalFormatter,
17
+ "uniword/validation/report/terminal_formatter"
18
+ end
19
+ end
20
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../report/validation_issue"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -64,6 +62,16 @@ module Uniword
64
62
  "error"
65
63
  end
66
64
 
65
+ # The context type this rule consumes. The Engine runs a rule
66
+ # only when the context's type matches: :package rules validate
67
+ # an on-disk DOCX via DocumentContext; :model rules validate an
68
+ # in-memory document model via ModelContext (see ModelRule).
69
+ #
70
+ # @return [Symbol] :package or :model
71
+ def context_type
72
+ :package
73
+ end
74
+
67
75
  # Check if this rule applies to the given document context.
68
76
  #
69
77
  # @param context [DocumentContext] The document being validated
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ module Rules
6
+ # DOC-201: every bookmarkStart must have a matching bookmarkEnd.
7
+ #
8
+ # Unpaired bookmark starts produce corrupt documents that Word
9
+ # refuses to open.
10
+ class BookmarkPairingRule < ModelRule
11
+ def code = "DOC-201"
12
+ def category = :bookmarks
13
+ def severity = "error"
14
+
15
+ def check(context)
16
+ body = context.document.body
17
+ return [] unless body
18
+
19
+ end_ids = (body.bookmark_ends || []).filter_map(&:id).to_set
20
+ (body.bookmark_starts || []).filter_map do |start|
21
+ unpaired_issue(start, end_ids)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # @return [Report::ValidationIssue, nil] issue when unpaired
28
+ def unpaired_issue(start, end_ids)
29
+ return if end_ids.include?(start.id)
30
+
31
+ issue("bookmarkStart id='#{start.id}' (name='#{start.name}') " \
32
+ "has no matching bookmarkEnd",
33
+ part: "word/document.xml")
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ module Rules
6
+ # DOC-202: bookmark names should be unique.
7
+ #
8
+ # Duplicate names confuse cross-references and GoTo navigation.
9
+ # The Word-internal "_GoBack" bookmark is exempt.
10
+ class BookmarkUniquenessRule < ModelRule
11
+ def code = "DOC-202"
12
+ def category = :bookmarks
13
+ def severity = "warning"
14
+
15
+ def check(context)
16
+ body = context.document.body
17
+ return [] unless body
18
+
19
+ duplicates(body.bookmark_starts || []).map do |name|
20
+ issue("Duplicate bookmark name '#{name}'",
21
+ part: "word/document.xml")
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # @param starts [Array] bookmarkStart models
28
+ # @return [Array<String>] names seen more than once
29
+ def duplicates(starts)
30
+ seen = {}
31
+ starts.each_with_object([]) do |start, dups|
32
+ name = tracked_name(start)
33
+ next unless name
34
+
35
+ dups << name if seen[name]
36
+ seen[name] = true
37
+ end
38
+ end
39
+
40
+ # @return [String, nil] name worth tracking, nil when exempt
41
+ def tracked_name(start)
42
+ name = start.name.to_s
43
+ name unless name.empty? || name == "_GoBack"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ module Rules
6
+ # DOC-200: the document must have a body.
7
+ #
8
+ # A w:document without w:body carries no content; every document
9
+ # must contain exactly one body element.
10
+ class DocumentBodyRule < ModelRule
11
+ def code = "DOC-200"
12
+ def category = :structure
13
+ def severity = "error"
14
+
15
+ def check(context)
16
+ return [] if context.document.body
17
+
18
+ [issue("Document body is missing",
19
+ part: "word/document.xml",
20
+ suggestion: "Add a w:body element to the document.")]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -32,6 +32,13 @@ module Uniword
32
32
  @moxml = Moxml.new(:nokogiri)
33
33
  end
34
34
 
35
+ # Context type used by the Engine to select rules.
36
+ #
37
+ # @return [Symbol] :package — this context wraps an on-disk DOCX
38
+ def context_type
39
+ :package
40
+ end
41
+
35
42
  # Open the ZIP archive.
36
43
  #
37
44
  # @return [Zip::File]
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ module Rules
6
+ # DOC-203: paragraphs should not be empty.
7
+ #
8
+ # Empty paragraphs are usually leftover scaffolding; reported as
9
+ # warnings only.
10
+ class EmptyParagraphsRule < ModelRule
11
+ def code = "DOC-203"
12
+ def category = :structure
13
+ def severity = "warning"
14
+
15
+ def check(context)
16
+ paragraphs = context.document.paragraphs
17
+ paragraphs.each_with_index.filter_map do |para, idx|
18
+ next unless para.runs.nil? || para.runs.empty?
19
+
20
+ issue("Empty paragraph at index #{idx}",
21
+ part: "word/document.xml")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ module Rules
6
+ # In-memory validation context: wraps a document model.
7
+ #
8
+ # Counterpart to DocumentContext (which wraps an on-disk DOCX
9
+ # package). Model-level rules consume this context via the Engine.
10
+ #
11
+ # @example Validate a built document
12
+ # context = ModelContext.new(document)
13
+ # issues = Validation::Engine.run(context)
14
+ class ModelContext
15
+ # @return [Wordprocessingml::DocumentRoot] the document model
16
+ attr_reader :document
17
+
18
+ # @param document [Wordprocessingml::DocumentRoot] in-memory model
19
+ def initialize(document)
20
+ @document = document
21
+ end
22
+
23
+ # Context type used by the Engine to select rules.
24
+ #
25
+ # @return [Symbol] :model — this context wraps an in-memory model
26
+ def context_type
27
+ :model
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Validation
5
+ module Rules
6
+ # Base class for in-memory (model-level) validation rules.
7
+ #
8
+ # Model rules run against a Rules::ModelContext — the pre-save
9
+ # front-end of the validation engine — and inspect the document
10
+ # model directly instead of parsing package XML.
11
+ #
12
+ # @example Implement a model rule
13
+ # class MyModelRule < ModelRule
14
+ # def code = "DOC-900"
15
+ #
16
+ # def check(context)
17
+ # return [] if context.document.body
18
+ #
19
+ # [issue("Document body is missing")]
20
+ # end
21
+ # end
22
+ #
23
+ # Rules::Registry.register(MyModelRule)
24
+ class ModelRule < Base
25
+ # Model rules consume the in-memory ModelContext.
26
+ #
27
+ # @return [Symbol] :model
28
+ def context_type
29
+ :model
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
4
-
5
3
  module Uniword
6
4
  module Validation
7
5
  module Rules
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zip"
4
- require_relative "base"
5
4
 
6
5
  module Uniword
7
6
  module Validation