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
@@ -15,125 +15,109 @@ module Uniword
15
15
 
16
16
  # Class methods for package construction
17
17
  module ClassMethods
18
+ # Part kinds in a minimal [Content_Types].xml, emission order.
19
+ MINIMAL_CT_DEFAULT_PARTS = %i[rels xml].freeze
20
+ MINIMAL_CT_OVERRIDE_PARTS =
21
+ %i[document styles font_table settings web_settings
22
+ app_properties core_properties].freeze
23
+
24
+ # Part kinds in a minimal _rels/.rels, in rId order.
25
+ MINIMAL_PACKAGE_REL_PARTS =
26
+ %i[document core_properties app_properties].freeze
27
+
28
+ # Part kinds in a minimal word/_rels/document.xml.rels,
29
+ # in rId order.
30
+ MINIMAL_DOCUMENT_REL_PARTS =
31
+ %i[styles settings web_settings font_table].freeze
32
+
18
33
  # Copy parts from document to package for round-trip preservation
34
+ #
35
+ # Registry-driven: every PartDefinition naming both a document
36
+ # and a package attribute is mirrored, honoring the
37
+ # definition's optional +to_package_guard+ predicate (e.g. the
38
+ # lazily-initialized numbering configuration) and
39
+ # +to_package_type+ value constraint (e.g. comments).
19
40
  def copy_document_parts_to_package(document, package)
20
41
  return unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
21
42
 
22
- package.styles = document.styles_configuration if document.styles_configuration
23
- package.settings = document.settings if document.settings
24
- package.font_table = document.font_table if document.font_table
25
- package.web_settings = document.web_settings if document.web_settings
26
- package.theme = document.theme if document.theme
27
- package.core_properties = document.core_properties if document.core_properties
28
- package.app_properties = document.app_properties if document.app_properties
29
- package.custom_properties = document.custom_properties if document.custom_properties
30
- package.document_rels = document.document_rels if document.document_rels
31
- package.theme_rels = document.theme_rels if document.theme_rels
32
- package.package_rels = document.package_rels if document.package_rels
33
- package.content_types = document.content_types if document.content_types
34
- package.footnotes = document.footnotes if document.footnotes
35
- package.endnotes = document.endnotes if document.endnotes
36
- package.allocator = document.allocator if document.allocator
37
-
38
- package.numbering = document.numbering_configuration if document.numbering_configuration_loaded?
43
+ Ooxml::PartRegistry.copied_to_package.each do |definition|
44
+ copy_part_to_package(document, package, definition)
45
+ end
39
46
 
40
- package.chart_parts = document.chart_parts if document.chart_parts
41
- package.custom_xml_items = document.custom_xml_items if document.custom_xml_items
42
- package.bibliography_sources = document.bibliography_sources if document.bibliography_sources
43
- package.embeddings = document.embeddings if document.embeddings
47
+ package.allocator = document.allocator if document.allocator
44
48
  end
45
49
 
46
50
  # Create minimal content types for a valid DOCX
47
51
  def minimal_content_types
48
52
  ct = Uniword::ContentTypes::Types.new
49
- ct.defaults ||= []
50
- ct.defaults << Uniword::ContentTypes::Default.new(
51
- extension: "rels",
52
- content_type: "application/vnd.openxmlformats-package.relationships+xml",
53
- )
54
- ct.defaults << Uniword::ContentTypes::Default.new(
55
- extension: "xml",
56
- content_type: "application/xml",
57
- )
58
-
59
- ct.overrides ||= []
60
- ct.overrides << Uniword::ContentTypes::Override.new(
61
- part_name: "/word/document.xml",
62
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
63
- )
64
- ct.overrides << Uniword::ContentTypes::Override.new(
65
- part_name: "/word/styles.xml",
66
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
67
- )
68
- ct.overrides << Uniword::ContentTypes::Override.new(
69
- part_name: "/word/fontTable.xml",
70
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml",
71
- )
72
- ct.overrides << Uniword::ContentTypes::Override.new(
73
- part_name: "/word/settings.xml",
74
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
75
- )
76
- ct.overrides << Uniword::ContentTypes::Override.new(
77
- part_name: "/word/webSettings.xml",
78
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml",
79
- )
80
- ct.overrides << Uniword::ContentTypes::Override.new(
81
- part_name: "/docProps/app.xml",
82
- content_type: "application/vnd.openxmlformats-officedocument.extended-properties+xml",
83
- )
84
- ct.overrides << Uniword::ContentTypes::Override.new(
85
- part_name: "/docProps/core.xml",
86
- content_type: "application/vnd.openxmlformats-package.core-properties+xml",
87
- )
53
+ ct.defaults = MINIMAL_CT_DEFAULT_PARTS.map do |key|
54
+ defn = Ooxml::PartRegistry.find_by_key(key)
55
+ Uniword::ContentTypes::Default.new(
56
+ extension: defn.extension,
57
+ content_type: defn.content_type,
58
+ )
59
+ end
60
+ ct.overrides = MINIMAL_CT_OVERRIDE_PARTS.map do |key|
61
+ defn = Ooxml::PartRegistry.find_by_key(key)
62
+ Uniword::ContentTypes::Override.new(
63
+ part_name: defn.part_name,
64
+ content_type: defn.content_type,
65
+ )
66
+ end
88
67
  ct
89
68
  end
90
69
 
91
70
  # Create minimal package relationships for a valid DOCX
92
71
  def minimal_package_rels
93
- rels = Ooxml::Relationships::PackageRelationships.new
94
- rels.relationships ||= []
95
- rels.relationships << Ooxml::Relationships::Relationship.new(
96
- id: "rId1",
97
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
98
- target: "word/document.xml",
99
- )
100
- rels.relationships << Ooxml::Relationships::Relationship.new(
101
- id: "rId2",
102
- type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
103
- target: "docProps/core.xml",
104
- )
105
- rels.relationships << Ooxml::Relationships::Relationship.new(
106
- id: "rId3",
107
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
108
- target: "docProps/app.xml",
109
- )
110
- rels
72
+ build_minimal_rels(MINIMAL_PACKAGE_REL_PARTS)
111
73
  end
112
74
 
113
75
  # Create minimal document relationships for a valid DOCX
114
76
  def minimal_document_rels
77
+ build_minimal_rels(MINIMAL_DOCUMENT_REL_PARTS)
78
+ end
79
+
80
+ private
81
+
82
+ # Copy one document part to the package when the definition's
83
+ # guard predicate and value type constraint allow it.
84
+ def copy_part_to_package(document, package, definition)
85
+ return unless copyable_to_package?(document, definition)
86
+
87
+ value = document.method(definition.document_attribute).call
88
+ return if value.nil?
89
+ return unless copyable_type?(definition, value)
90
+
91
+ package.method(:"#{definition.package_attribute}=").call(value)
92
+ end
93
+
94
+ # The guard predicate named by the definition (a method on the
95
+ # document, e.g. :numbering_configuration_loaded?) decides;
96
+ # without a guard the part copies.
97
+ def copyable_to_package?(document, definition)
98
+ guard = definition.to_package_guard
99
+ guard.nil? || document.method(guard).call
100
+ end
101
+
102
+ # When the definition constrains the value type (e.g.
103
+ # CommentsPart for comments), the value must satisfy it.
104
+ def copyable_type?(definition, value)
105
+ type = definition.to_package_type
106
+ type.nil? || value.is_a?(type)
107
+ end
108
+
109
+ # Build a Relationships part with sequential rIds (rId1..rIdN)
110
+ # for the given registry part keys, in the given order.
111
+ def build_minimal_rels(part_keys)
115
112
  rels = Ooxml::Relationships::PackageRelationships.new
116
- rels.relationships ||= []
117
- rels.relationships << Ooxml::Relationships::Relationship.new(
118
- id: "rId1",
119
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
120
- target: "styles.xml",
121
- )
122
- rels.relationships << Ooxml::Relationships::Relationship.new(
123
- id: "rId2",
124
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings",
125
- target: "settings.xml",
126
- )
127
- rels.relationships << Ooxml::Relationships::Relationship.new(
128
- id: "rId3",
129
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings",
130
- target: "webSettings.xml",
131
- )
132
- rels.relationships << Ooxml::Relationships::Relationship.new(
133
- id: "rId4",
134
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable",
135
- target: "fontTable.xml",
136
- )
113
+ rels.relationships = part_keys.each_with_index.map do |key, idx|
114
+ defn = Ooxml::PartRegistry.find_by_key(key)
115
+ Ooxml::Relationships::Relationship.new(
116
+ id: "rId#{idx + 1}",
117
+ type: defn.rel_type,
118
+ target: defn.target,
119
+ )
120
+ end
137
121
  rels
138
122
  end
139
123
  end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "set"
5
+
6
+ module Uniword
7
+ module Docx
8
+ # Write-time OPC package integrity gate.
9
+ #
10
+ # Pure, non-mutating checker invoked on the in-memory ZIP content hash
11
+ # after reconciliation and relationship injection, before ZipPackager.
12
+ # MECE with the mutating Reconciler: the Reconciler repairs what it can,
13
+ # this checker refuses output that would produce an invalid package.
14
+ #
15
+ # Issue codes mirror the post-hoc OpcValidator semantics
16
+ # (lib/uniword/validation/opc_validator.rb) where they match:
17
+ # - OPC-005: every ZIP entry has a content type (Default or Override)
18
+ # - OPC-006: every relationship target resolves to a package entry
19
+ # - OPC-008: every emitted XML part is well-formed
20
+ # Codes introduced by the write-time gate:
21
+ # - OPC-009: every r:id/r:embed/r:link reference in any XML part
22
+ # resolves to a Relationship in that part's .rels
23
+ # - OPC-010: relationship IDs are unique within every .rels part
24
+ #
25
+ # @example Check an in-memory content hash
26
+ # issues = PackageIntegrityChecker.new.check(zip_content)
27
+ # issues.each { |i| puts "#{i.code} [#{i.part}] #{i.message}" }
28
+ class PackageIntegrityChecker
29
+ # Package relationships part namespace (OPC).
30
+ RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
31
+
32
+ # Office document relationship attribute namespace (r:id, r:embed...).
33
+ OFFICE_RELS_NS =
34
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
35
+
36
+ # [Content_Types].xml namespace.
37
+ CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
38
+
39
+ # Relationship attribute local names that reference rIds.
40
+ R_REF_ATTRIBUTES = %w[id embed link].freeze
41
+
42
+ # Check an in-memory package content hash against every invariant.
43
+ #
44
+ # One issue-collector pass; never mutates the content hash.
45
+ #
46
+ # @param content [Hash{String => String}] ZIP entry path => content
47
+ # @return [Array<Validation::Report::ValidationIssue>] Issues found
48
+ # (empty when the package is valid)
49
+ def check(content)
50
+ @content = content
51
+ @issues = []
52
+ @parsed_parts = {}
53
+
54
+ parse_all_parts # OPC-008, memoizes documents for later checks
55
+ check_content_type_coverage # OPC-005
56
+ check_relationship_targets # OPC-006
57
+ check_relationship_references # OPC-009
58
+ check_rid_uniqueness # OPC-010
59
+
60
+ @issues
61
+ end
62
+
63
+ private
64
+
65
+ # -- Shared parsing -------------------------------------------------
66
+
67
+ # Entries whose content must be well-formed XML.
68
+ #
69
+ # @return [Array<String>] Entry paths ending in .xml or .rels
70
+ def parsable_parts
71
+ @content.keys.select do |name|
72
+ name.end_with?(".xml", ".rels")
73
+ end
74
+ end
75
+
76
+ # Strict-parse every XML entry once, recording OPC-008 on failure.
77
+ #
78
+ # @return [void]
79
+ def parse_all_parts
80
+ parsable_parts.each { |name| parsed_part(name) }
81
+ end
82
+
83
+ # Memoized strict parse of one entry.
84
+ #
85
+ # @param name [String] Entry path
86
+ # @return [Nokogiri::XML::Document, nil] Parsed document, or nil when
87
+ # the entry is malformed (OPC-008 already recorded)
88
+ def parsed_part(name)
89
+ return @parsed_parts[name] if @parsed_parts.key?(name)
90
+
91
+ @parsed_parts[name] =
92
+ begin
93
+ Nokogiri::XML(@content[name], &:strict)
94
+ rescue Nokogiri::XML::SyntaxError => e
95
+ add_issue("OPC-008", name,
96
+ "Malformed XML in #{name}: #{e.message}")
97
+ nil
98
+ end
99
+ end
100
+
101
+ # -- OPC-005: content-type coverage ----------------------------------
102
+
103
+ def check_content_type_coverage
104
+ ct_name = "[Content_Types].xml"
105
+ unless @content.key?(ct_name)
106
+ add_issue("OPC-005", ct_name,
107
+ "#{ct_name} is missing; no entry has a declared " \
108
+ "content type")
109
+ return
110
+ end
111
+
112
+ ct_doc = parsed_part(ct_name)
113
+ return unless ct_doc # malformed already reported as OPC-008
114
+
115
+ defaults = ct_doc.xpath("//xmlns:Default", "xmlns" => CT_NS)
116
+ .filter_map { |node| node["Extension"] }.to_set
117
+ overrides = ct_doc.xpath("//xmlns:Override", "xmlns" => CT_NS)
118
+ .filter_map { |node| node["PartName"] }.to_set
119
+
120
+ @content.each_key do |name|
121
+ next if name.end_with?(".rels") # implicit content type per OPC
122
+
123
+ ext = File.extname(name)[1..]
124
+ next if ext && defaults.include?(ext)
125
+ next if overrides.include?("/#{name}")
126
+
127
+ add_issue("OPC-005", name,
128
+ "No content type declared for #{name}")
129
+ end
130
+ end
131
+
132
+ # -- OPC-006: relationship target existence --------------------------
133
+
134
+ def check_relationship_targets
135
+ rels_parts.each do |rels_name|
136
+ doc = parsed_part(rels_name)
137
+ next unless doc
138
+
139
+ base_dir = rels_base_dir(rels_name)
140
+ relationship_nodes(doc).each do |node|
141
+ target = node["Target"]
142
+ next if node["TargetMode"] == "External"
143
+ next unless target
144
+ next if target.start_with?("#")
145
+
146
+ resolved = resolve_target(base_dir, target)
147
+ next if @content.key?(resolved)
148
+
149
+ add_issue("OPC-006", rels_name,
150
+ "Relationship target not found: #{resolved} " \
151
+ "(referenced from #{rels_name})")
152
+ end
153
+ end
154
+ end
155
+
156
+ # -- OPC-009: r:id / r:embed / r:link resolution ---------------------
157
+
158
+ def check_relationship_references
159
+ @content.keys.each do |name|
160
+ next unless name.end_with?(".xml")
161
+
162
+ doc = parsed_part(name)
163
+ next unless doc
164
+
165
+ refs = relationship_references(doc)
166
+ next if refs.empty?
167
+
168
+ rels_name = rels_path_for(name)
169
+ valid_ids = relationship_ids(rels_name)
170
+
171
+ refs.each do |attr_name, rid|
172
+ next if valid_ids.include?(rid)
173
+
174
+ add_issue("OPC-009", name,
175
+ "Relationship reference r:#{attr_name}=\"#{rid}\" in " \
176
+ "#{name} has no matching Relationship in #{rels_name}")
177
+ end
178
+ end
179
+ end
180
+
181
+ # Collect (attribute local name, value) pairs for r:id/r:embed/r:link.
182
+ #
183
+ # @param doc [Nokogiri::XML::Document] Parsed XML part
184
+ # @return [Array<Array(String, String)>] Reference pairs
185
+ def relationship_references(doc)
186
+ refs = []
187
+ doc.traverse do |node|
188
+ next unless node.element?
189
+
190
+ node.attribute_nodes.each do |attr|
191
+ next unless attr.namespace&.href == OFFICE_RELS_NS
192
+ next unless R_REF_ATTRIBUTES.include?(attr.node_name)
193
+
194
+ value = attr.value
195
+ refs << [attr.node_name, value] unless value.empty?
196
+ end
197
+ end
198
+ refs
199
+ end
200
+
201
+ # Relationship IDs declared in one .rels entry.
202
+ #
203
+ # @param rels_name [String] .rels entry path
204
+ # @return [Set<String>] Declared relationship IDs (empty when the
205
+ # .rels entry is absent or malformed)
206
+ def relationship_ids(rels_name)
207
+ return Set.new unless @content.key?(rels_name)
208
+
209
+ doc = parsed_part(rels_name)
210
+ return Set.new unless doc
211
+
212
+ relationship_nodes(doc).filter_map { |node| node["Id"] }.to_set
213
+ end
214
+
215
+ # -- OPC-010: rId uniqueness ------------------------------------------
216
+
217
+ def check_rid_uniqueness
218
+ rels_parts.each do |rels_name|
219
+ doc = parsed_part(rels_name)
220
+ next unless doc
221
+
222
+ seen = {}
223
+ relationship_nodes(doc).each do |node|
224
+ id = node["Id"]
225
+ next unless id
226
+
227
+ if seen[id]
228
+ add_issue("OPC-010", rels_name,
229
+ "Duplicate relationship ID #{id} in #{rels_name}")
230
+ else
231
+ seen[id] = true
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ # -- Helpers ----------------------------------------------------------
238
+
239
+ # @return [Array<String>] .rels entry paths in the content hash
240
+ def rels_parts
241
+ @content.keys.select { |name| name.end_with?(".rels") }
242
+ end
243
+
244
+ # Relationship elements of a parsed .rels document.
245
+ #
246
+ # @param doc [Nokogiri::XML::Document] Parsed .rels part
247
+ # @return [Nokogiri::XML::NodeSet] Relationship elements
248
+ def relationship_nodes(doc)
249
+ doc.xpath("//xmlns:Relationship", "xmlns" => RELS_NS)
250
+ end
251
+
252
+ # Base directory a .rels entry resolves targets against.
253
+ # "_rels/.rels" => "" (package root);
254
+ # "word/_rels/document.xml.rels" => "word".
255
+ #
256
+ # @param rels_name [String] .rels entry path
257
+ # @return [String] Base directory ("" for the package root)
258
+ def rels_base_dir(rels_name)
259
+ return "" if rels_name.start_with?("_rels/")
260
+
261
+ rels_name.sub(%r{/_rels/.*$}, "")
262
+ end
263
+
264
+ # The .rels entry path belonging to a part.
265
+ # "word/document.xml" => "word/_rels/document.xml.rels".
266
+ #
267
+ # @param part_name [String] Part entry path
268
+ # @return [String] Corresponding .rels entry path
269
+ def rels_path_for(part_name)
270
+ dir = File.dirname(part_name)
271
+ File.join(dir, "_rels", "#{File.basename(part_name)}.rels")
272
+ end
273
+
274
+ # Resolve a relationship target to a normalized entry path,
275
+ # handling leading-slash package-absolute targets and ".." segments.
276
+ #
277
+ # @param base_dir [String] Owning part's directory ("" for root)
278
+ # @param target [String] Raw Target attribute value
279
+ # @return [String] Normalized entry path
280
+ def resolve_target(base_dir, target)
281
+ return target[1..] if target.start_with?("/")
282
+
283
+ normalize_package_path(File.join(base_dir, target))
284
+ end
285
+
286
+ # Lexically normalize a package-relative path (resolve "." and ".."
287
+ # segments). Deliberately avoids File.expand_path, which prepends a
288
+ # drive letter on Windows.
289
+ #
290
+ # @param path [String] Package-relative path
291
+ # @return [String] Normalized path
292
+ def normalize_package_path(path)
293
+ segments = []
294
+ path.split("/").each do |segment|
295
+ next if segment.empty? || segment == "."
296
+
297
+ segment == ".." ? segments.pop : segments << segment
298
+ end
299
+ segments.join("/")
300
+ end
301
+
302
+ def add_issue(code, part, message)
303
+ @issues << Validation::Report::ValidationIssue.new(
304
+ severity: "error",
305
+ code: code,
306
+ message: message,
307
+ part: part,
308
+ )
309
+ end
310
+ end
311
+ end
312
+ end