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
@@ -8,8 +8,11 @@ module Uniword
8
8
  #
9
9
  # Generated from OOXML schema: shared_types.yml
10
10
  # Element: <st:pixel_measure>
11
+ #
12
+ # Value constrained to ST_PixelsMeasure (ECMA-376): non-negative
13
+ # integer; see Ooxml::Types::UnsignedDecimalNumber.
11
14
  class PixelMeasure < Lutaml::Model::Serializable
12
- attribute :val, :integer
15
+ attribute :val, Uniword::Ooxml::Types::UnsignedDecimalNumber
13
16
 
14
17
  xml do
15
18
  element "pixel_measure"
@@ -8,8 +8,11 @@ module Uniword
8
8
  #
9
9
  # Generated from OOXML schema: shared_types.yml
10
10
  # Element: <st:point_measure>
11
+ #
12
+ # Value constrained to ST_PointMeasure (ECMA-376): non-negative
13
+ # integer; see Ooxml::Types::UnsignedDecimalNumber.
11
14
  class PointMeasure < Lutaml::Model::Serializable
12
- attribute :val, :integer
15
+ attribute :val, Uniword::Ooxml::Types::UnsignedDecimalNumber
13
16
 
14
17
  xml do
15
18
  element "point_measure"
@@ -8,8 +8,13 @@ module Uniword
8
8
  #
9
9
  # Generated from OOXML schema: shared_types.yml
10
10
  # Element: <st:text_alignment>
11
+ #
12
+ # Value constrained to ST_TextAlignment (ECMA-376, wml.xsd).
11
13
  class TextAlignment < Lutaml::Model::Serializable
12
- attribute :val, :string
14
+ # Full ST_TextAlignment enumeration from ECMA-376 (wml.xsd)
15
+ VALUES = %w[top center baseline bottom auto].freeze
16
+
17
+ attribute :val, :string, values: VALUES
13
18
 
14
19
  xml do
15
20
  element "text_alignment"
@@ -8,8 +8,11 @@ module Uniword
8
8
  #
9
9
  # Generated from OOXML schema: shared_types.yml
10
10
  # Element: <st:twips_measure>
11
+ #
12
+ # Value constrained to ST_TwipsMeasure (ECMA-376): non-negative
13
+ # integer; see Ooxml::Types::UnsignedDecimalNumber.
11
14
  class TwipsMeasure < Lutaml::Model::Serializable
12
- attribute :val, :integer
15
+ attribute :val, Uniword::Ooxml::Types::UnsignedDecimalNumber
13
16
 
14
17
  xml do
15
18
  element "twips_measure"
@@ -26,7 +26,7 @@ module Uniword
26
26
  map_attribute "panose", to: :panose, render_nil: false
27
27
  map_attribute "pitchFamily", to: :pitch_family, render_nil: false
28
28
  map_attribute "charset", to: :charset, render_nil: false
29
- map_attribute "altText", to: :AltText, render_nil: false
29
+ map_attribute "altText", to: :alt_text, render_nil: false
30
30
  map_attribute "combinationId", to: :combination_id, render_nil: false
31
31
  map_attribute "growingEditing", to: :growing_editing, render_nil: false
32
32
  end
@@ -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
@@ -92,11 +92,12 @@ module Uniword
92
92
  return unless body.element_order
93
93
 
94
94
  insert_element = Lutaml::Xml::Element.new("Element", "sdt")
95
- if position.positive? && body.element_order.size >= position
96
- body.element_order.insert(position, insert_element)
97
- else
98
- body.element_order.insert(0, insert_element)
99
- end
95
+ index = if position.positive? && body.element_order.size >= position
96
+ position
97
+ else
98
+ 0
99
+ end
100
+ Ooxml::ElementOrder.insert_at(body, index, insert_element)
100
101
  end
101
102
 
102
103
  # Update an existing TOC in the document.
@@ -219,11 +219,7 @@ module Uniword
219
219
  def resolve_image_target(embed_id)
220
220
  return nil unless @image_parts
221
221
 
222
- entry = @image_parts.find { |pair| pair[0] == embed_id }
223
- return nil unless entry
224
-
225
- image_data = entry[1]
226
- image_data[:target]
222
+ @image_parts[embed_id]&.target
227
223
  end
228
224
 
229
225
  # Apply run formatting (text must already be HTML-escaped)
@@ -63,8 +63,8 @@ module Uniword
63
63
  # Build filelist.xml part
64
64
  def build_filelist_part
65
65
  image_entries = if @document.image_parts && !@document.image_parts.empty?
66
- @document.image_parts.map do |_r_id, image_data|
67
- %(<o:File HRef="#{image_data[:target]}"/>)
66
+ @document.image_parts.map do |_r_id, part|
67
+ %(<o:File HRef="#{part.target}"/>)
68
68
  end.join("\n ")
69
69
  else
70
70
  ""
@@ -91,12 +91,12 @@ module Uniword
91
91
  def build_image_parts
92
92
  return [] unless @document.image_parts && !@document.image_parts.empty?
93
93
 
94
- @document.image_parts.map do |_r_id, image_data|
94
+ @document.image_parts.map do |_r_id, image_part|
95
95
  part = Uniword::Mhtml::ImagePart.new
96
- part.content_type = image_data[:content_type] || "image/png"
96
+ part.content_type = image_part.content_type || "image/png"
97
97
  part.content_transfer_encoding = "base64"
98
- part.raw_content = image_data[:data]
99
- part.content_location = "file:///C:/D057922B/#{document_name}.fld/#{image_data[:target]}"
98
+ part.raw_content = image_part.data
99
+ part.content_location = "file:///C:/D057922B/#{document_name}.fld/#{image_part.target}"
100
100
  part
101
101
  end
102
102
  end
@@ -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
@@ -222,7 +221,22 @@ module Uniword
222
221
  def resolve_target(base_dir, target)
223
222
  return target[1..] if target.start_with?("/")
224
223
 
225
- base_dir.empty? ? target : File.join(base_dir, target)
224
+ normalize_package_path(
225
+ base_dir.empty? ? target : File.join(base_dir, target),
226
+ )
227
+ end
228
+
229
+ # Lexically normalize a package-relative path (resolve "." and
230
+ # ".." segments). Deliberately avoids File.expand_path, which
231
+ # prepends a drive letter on Windows.
232
+ def normalize_package_path(path)
233
+ segments = []
234
+ path.split("/").each do |segment|
235
+ next if segment.empty? || segment == "."
236
+
237
+ segment == ".." ? segments.pop : segments << segment
238
+ end
239
+ segments.join("/")
226
240
  end
227
241
  end
228
242
  end
@@ -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