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
@@ -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