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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # Kind-filtered view over a document's HeaderFooterPartCollection.
6
+ #
7
+ # +document.headers+ and +document.footers+ return these views, so
8
+ # the Builder, the headers/footers managers and the serializer all
9
+ # share the single underlying store.
10
+ #
11
+ # Two historic access shapes are preserved:
12
+ # - Hash-style (Builder): +view["default"] = header_model+,
13
+ # +view["default"]+, +each_value+, +values+, +keys+, +key?+,
14
+ # +delete+. Content access yields the Header/Footer models.
15
+ # - Array-style (managers): +view << header+, +each+, +map+,
16
+ # +find+, +reject+. Iteration yields HeaderFooterPart objects,
17
+ # which carry the sectPr +type+ and delegate +paragraphs+,
18
+ # +tables+ and +empty?+ to their content.
19
+ #
20
+ # Upsert semantics: assigning a type that already exists replaces
21
+ # that part's content in place (keeping its target and rId), so
22
+ # loaded and builder-created parts can never duplicate.
23
+ class HeaderFooterView
24
+ include Enumerable
25
+
26
+ # @return [Symbol] :header or :footer
27
+ attr_reader :kind
28
+
29
+ # @param store [HeaderFooterPartCollection] the unified store
30
+ # @param kind [Symbol] :header or :footer
31
+ def initialize(store, kind)
32
+ @store = store
33
+ @kind = kind
34
+ end
35
+
36
+ # Iterate over the parts of this kind (Array-style access).
37
+ def each(&block)
38
+ @store.of_kind(kind).each(&block)
39
+ end
40
+
41
+ # Hash-style read: content model for a sectPr type.
42
+ #
43
+ # @param type [String, Symbol] "default"/"first"/"even"
44
+ # @return [Wordprocessingml::Header, Wordprocessingml::Footer, nil]
45
+ def [](type)
46
+ @store.find_part(kind, type)&.content
47
+ end
48
+
49
+ # Hash-style write: upsert a content model by sectPr type.
50
+ # Replaces the existing part's content in place when the type
51
+ # exists; otherwise appends a new part with the next free
52
+ # numbered target.
53
+ #
54
+ # @param type [String, Symbol] sectPr reference type
55
+ # @param model [Object] Wordprocessingml::Header/Footer or
56
+ # legacy Uniword::Header/Footer
57
+ # @return [Object] the assigned model
58
+ def []=(type, model)
59
+ type = type.to_s
60
+ content = coerce_content(model)
61
+ part = @store.find_part(kind, type)
62
+ if part
63
+ part.content = content
64
+ part.loaded = false
65
+ else
66
+ @store << HeaderFooterPart.new(
67
+ kind: kind, type: type, content: content,
68
+ target: @store.next_target(kind)
69
+ )
70
+ end
71
+ model
72
+ end
73
+
74
+ # Append a model (Array-style). Legacy Uniword::Header/Footer
75
+ # models supply their own +type+; other models are stored
76
+ # without a type.
77
+ #
78
+ # @param model [Object] content model or HeaderFooterPart
79
+ # @return [Object] the appended model
80
+ def <<(model)
81
+ if model.is_a?(HeaderFooterPart)
82
+ adopt_part(model)
83
+ else
84
+ append_model(model)
85
+ end
86
+ model
87
+ end
88
+
89
+ # Iterate over sectPr types.
90
+ def each_key(&block)
91
+ map(&:type).each(&block)
92
+ end
93
+
94
+ # Iterate over content models.
95
+ def each_value(&block)
96
+ map(&:content).each(&block)
97
+ end
98
+
99
+ # @return [Array<String, nil>] sectPr types
100
+ def keys
101
+ map(&:type)
102
+ end
103
+
104
+ # @return [Array] content models
105
+ def values
106
+ map(&:content)
107
+ end
108
+
109
+ # @param type [String, Symbol]
110
+ # @return [Boolean]
111
+ def key?(type)
112
+ !@store.find_part(kind, type).nil?
113
+ end
114
+
115
+ # Remove the part for a sectPr type.
116
+ #
117
+ # @param type [String, Symbol]
118
+ # @return [Object, nil] the removed part's content
119
+ def delete(type)
120
+ part = @store.find_part(kind, type)
121
+ return nil unless part
122
+
123
+ @store.delete(part)
124
+ part.content
125
+ end
126
+
127
+ # @return [Integer]
128
+ def size
129
+ @store.of_kind(kind).size
130
+ end
131
+
132
+ # @return [Boolean]
133
+ def empty?
134
+ size.zero?
135
+ end
136
+
137
+ # Bulk assignment (+document.headers = value+). Accepts nil
138
+ # (clears this kind), a Hash of type => model, an Array of
139
+ # models/parts, or the view itself (no-op).
140
+ #
141
+ # @param value [nil, Hash, Array, HeaderFooterView]
142
+ # @return [Object] the assigned value
143
+ def replace(value)
144
+ case value
145
+ when nil then @store.replace_kind(kind, [])
146
+ when HeaderFooterView then nil
147
+ when Hash then replace_from_hash(value)
148
+ when Array then replace_from_array(value)
149
+ else
150
+ raise ArgumentError,
151
+ "cannot assign #{value.class} to document.#{kind}s"
152
+ end
153
+ value
154
+ end
155
+
156
+ private
157
+
158
+ def adopt_part(part)
159
+ part.target ||= @store.next_target(part.kind || kind)
160
+ @store << part
161
+ end
162
+
163
+ def append_model(model)
164
+ type = legacy_type(model)
165
+ return self[type] = model if type
166
+
167
+ @store << HeaderFooterPart.new(
168
+ kind: kind, content: coerce_content(model),
169
+ target: @store.next_target(kind)
170
+ )
171
+ end
172
+
173
+ def replace_from_hash(hash)
174
+ hash.each { |type, model| self[type] = model }
175
+ kept = hash.keys.map(&:to_s)
176
+ @store.of_kind(kind).each do |part|
177
+ @store.delete(part) if stale_fresh_part?(part, kept)
178
+ end
179
+ end
180
+
181
+ # A programmatically-added part is stale after a Hash assignment
182
+ # when its type is not among the assigned keys (loaded parts are
183
+ # never removed by view assignment).
184
+ def stale_fresh_part?(part, kept_types)
185
+ !part.loaded? && !part.type.nil? && !kept_types.include?(part.type)
186
+ end
187
+
188
+ def replace_from_array(array)
189
+ parts = array.map { |entry| coerce_to_part(entry) }
190
+ @store.replace_kind(kind, [])
191
+ parts.each do |part|
192
+ part.target ||= @store.next_target(kind)
193
+ @store << part
194
+ end
195
+ end
196
+
197
+ def coerce_to_part(entry)
198
+ case entry
199
+ when HeaderFooterPart then entry
200
+ when Hash then HeaderFooterPartCollection.wrap(entry)
201
+ else
202
+ HeaderFooterPart.new(
203
+ kind: kind, type: legacy_type(entry),
204
+ content: coerce_content(entry)
205
+ )
206
+ end
207
+ end
208
+
209
+ # Legacy Uniword::Header/Footer carry their sectPr type.
210
+ def legacy_type(model)
211
+ case model
212
+ when Uniword::Header, Uniword::Footer then model.type
213
+ end
214
+ end
215
+
216
+ # Content is stored as given: legacy Uniword::Header/Footer
217
+ # models stay live (callers may keep mutating them) and are
218
+ # converted to their Wordprocessingml counterparts only at
219
+ # serialization time (HeaderFooterPart#serializable_content).
220
+ def coerce_content(model)
221
+ model
222
+ end
223
+ end
224
+ end
225
+ end
@@ -17,22 +17,32 @@ module Uniword
17
17
  #
18
18
  # This is the "populate-first" principle: when loading a template DOCX,
19
19
  # parse and seed ALL existing IDs from the template before modification.
20
+ #
21
+ # rId namespaces are per relationships part: "rId1" in _rels/.rels and
22
+ # "rId1" in word/_rels/document.xml.rels do not collide. The allocator
23
+ # therefore tracks rIds per +scope+ (:document for document-level rels,
24
+ # :package for package-level rels); each scope has its own registry,
25
+ # counter and uniqueness domain.
20
26
  class IdAllocator
21
- REL_TYPE_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
22
-
23
- IMAGE_REL_TYPE = "#{REL_TYPE_BASE}/image"
24
- HEADER_REL_TYPE = "#{REL_TYPE_BASE}/header"
25
- FOOTER_REL_TYPE = "#{REL_TYPE_BASE}/footer"
26
- HYPERLINK_REL_TYPE = "#{REL_TYPE_BASE}/hyperlink"
27
- CHART_REL_TYPE = "#{REL_TYPE_BASE}/chart"
28
- FOOTNOTES_REL_TYPE = "#{REL_TYPE_BASE}/footnotes"
29
- ENDNOTES_REL_TYPE = "#{REL_TYPE_BASE}/endnotes"
30
- THEME_REL_TYPE = "#{REL_TYPE_BASE}/theme"
31
- NUMBERING_REL_TYPE = "#{REL_TYPE_BASE}/numbering"
27
+ # Relationship type namespace base (also the r: namespace URI).
28
+ # Individual rel type constants derive from Ooxml::PartRegistry,
29
+ # the single source of truth for part metadata.
30
+ REL_TYPE_BASE = Ooxml::PartRegistry::OFFICE_REL_BASE
31
+
32
+ IMAGE_REL_TYPE = Ooxml::PartRegistry.find_by_key(:image).rel_type
33
+ HEADER_REL_TYPE = Ooxml::PartRegistry.find_by_key(:header).rel_type
34
+ FOOTER_REL_TYPE = Ooxml::PartRegistry.find_by_key(:footer).rel_type
35
+ HYPERLINK_REL_TYPE = Ooxml::PartRegistry.find_by_key(:hyperlink).rel_type
36
+ CHART_REL_TYPE = Ooxml::PartRegistry.find_by_key(:chart).rel_type
37
+ FOOTNOTES_REL_TYPE = Ooxml::PartRegistry.find_by_key(:footnotes).rel_type
38
+ ENDNOTES_REL_TYPE = Ooxml::PartRegistry.find_by_key(:endnotes).rel_type
39
+ THEME_REL_TYPE = Ooxml::PartRegistry.find_by_key(:theme).rel_type
40
+ NUMBERING_REL_TYPE = Ooxml::PartRegistry.find_by_key(:numbering).rel_type
32
41
 
33
42
  def initialize
34
- @rid_counter = 0
35
- @rid_entries = {} # [target, type] -> { id, type, target, target_mode }
43
+ @rid_counters = Hash.new(0) # scope -> high-water mark
44
+ # [scope, target, type] -> { id, type, target, target_mode, scope }
45
+ @rid_entries = {}
36
46
  @footnote_counter = 1
37
47
  @endnote_counter = 1
38
48
  @bookmark_counter = 0
@@ -43,16 +53,56 @@ module Uniword
43
53
 
44
54
  # Allocate a relationship ID for a target+type pair.
45
55
  # Returns existing rId if this target+type was already registered.
46
- def alloc_rid(target:, type:, target_mode: nil)
47
- key = [target, type.to_s]
56
+ #
57
+ # @param target [String] relationship target
58
+ # @param type [String] relationship type
59
+ # @param target_mode [String, nil] e.g. "External"
60
+ # @param scope [Symbol] :document or :package (rels part namespace)
61
+ # @return [String] the allocated (or existing) rId
62
+ def alloc_rid(target:, type:, target_mode: nil, scope: :document)
63
+ key = [scope, target, type.to_s]
48
64
  @rid_entries[key] ||= begin
49
- @rid_counter += 1
50
- { id: "rId#{@rid_counter}", type: type.to_s,
51
- target: target, target_mode: target_mode }
65
+ @rid_counters[scope] += 1
66
+ @rid_counters[scope] += 1 while rid_id_taken?(
67
+ "rId#{@rid_counters[scope]}", scope
68
+ )
69
+ { id: "rId#{@rid_counters[scope]}", type: type.to_s,
70
+ target: target, target_mode: target_mode, scope: scope }
52
71
  end
53
72
  @rid_entries[key][:id]
54
73
  end
55
74
 
75
+ # Register an explicit rId for a target+type pair.
76
+ # Used when a relationship id is assigned outside the counter
77
+ # (rId dedup repair) so later lookups stay consistent.
78
+ #
79
+ # @param id [String] relationship ID to register
80
+ # @param target [String] relationship target
81
+ # @param type [String] relationship type
82
+ # @param target_mode [String, nil] e.g. "External"
83
+ # @param scope [Symbol] :document or :package
84
+ # @return [String] the registered id
85
+ def register_rid(id, target:, type:, target_mode: nil, scope: :document)
86
+ @rid_entries[[scope, target, type.to_s]] = {
87
+ id: id, type: type.to_s, target: target, target_mode: target_mode,
88
+ scope: scope
89
+ }
90
+ num = id[/\ArId(\d+)\z/, 1]&.to_i || 0
91
+ @rid_counters[scope] = [@rid_counters[scope], num].max
92
+ id
93
+ end
94
+
95
+ # The next free rId past the high-water mark of a scope —
96
+ # guaranteed not registered to any target+type in that scope.
97
+ #
98
+ # @param scope [Symbol] :document or :package
99
+ # @return [String] e.g. "rId7"
100
+ def next_free_rid(scope: :document)
101
+ candidate = @rid_counters[scope] + 1
102
+ candidate += 1 while rid_id_taken?("rId#{candidate}", scope)
103
+ "rId#{candidate}"
104
+ end
105
+
56
106
  def alloc_footnote_id
57
107
  id = @footnote_counter
58
108
  @footnote_counter += 1
@@ -85,21 +135,19 @@ module Uniword
85
135
  Digest::SHA256.hexdigest("rsid:#{@rsid_counter}").upcase[0, 8]
86
136
  end
87
137
 
88
- # Seed from a relationships collection — preserves existing rIds.
89
- def seed_from_rels(relationships)
138
+ # Seed from a relationships collection — preserves existing rIds
139
+ # verbatim so a load→save round-trip is rId-stable. A loaded rId
140
+ # already registered to a different target+type in the same scope
141
+ # (only possible when allocation preceded seeding, or the source
142
+ # has duplicate ids) yields a fresh counter allocation for the
143
+ # seeded pair instead — uniqueness wins over verbatim preservation.
144
+ #
145
+ # @param relationships [Array, nil] rels to seed
146
+ # @param scope [Symbol] :document or :package (rels part namespace)
147
+ def seed_from_rels(relationships, scope: :document)
90
148
  return unless relationships
91
149
 
92
- relationships.each do |r|
93
- key = [r.target, r.type.to_s]
94
- @rid_entries[key] = {
95
- id: r.id,
96
- type: r.type.to_s,
97
- target: r.target,
98
- target_mode: r.target_mode,
99
- }
100
- num = r.id[/\ArId(\d+)\z/, 1]&.to_i || 0
101
- @rid_counter = [@rid_counter, num].max
102
- end
150
+ relationships.each { |r| seed_rel(r, scope) }
103
151
  end
104
152
 
105
153
  # Seed footnote/endnote counters from existing note entries.
@@ -114,14 +162,26 @@ module Uniword
114
162
  end
115
163
  end
116
164
 
165
+ # Seed the comment counter from existing comment entries so newly
166
+ # allocated comment IDs do not collide with loaded ones.
167
+ def seed_from_comments(comments)
168
+ comments&.each do |c|
169
+ @comment_counter = [@comment_counter, c.comment_id.to_i].max
170
+ end
171
+ end
172
+
117
173
  # Produce the final ordered list of all allocated relationships.
118
- def all_rels
119
- @rid_entries.values.sort_by { |r| r[:id][/\d+/]&.to_i || 0 }
174
+ #
175
+ # @param scope [Symbol, nil] restrict to one scope; nil for all
176
+ def all_rels(scope: nil)
177
+ entries = @rid_entries.values
178
+ entries = entries.select { |r| r[:scope] == scope } if scope
179
+ entries.sort_by { |r| r[:id][/\d+/]&.to_i || 0 }
120
180
  end
121
181
 
122
182
  # Check if a relationship has been registered for a target+type.
123
- def rid_for(target:, type:)
124
- key = [target, type.to_s]
183
+ def rid_for(target:, type:, scope: :document)
184
+ key = [scope, target, type.to_s]
125
185
  @rid_entries[key]&.fetch(:id, nil)
126
186
  end
127
187
 
@@ -131,13 +191,47 @@ module Uniword
131
191
  def self.populate_from_package(package)
132
192
  alloc = new
133
193
  alloc.seed_from_rels(package.document_rels&.relationships)
134
- alloc.seed_from_rels(package.package_rels&.relationships)
194
+ alloc.seed_from_rels(package.package_rels&.relationships,
195
+ scope: :package)
135
196
  alloc.seed_from_notes(
136
197
  package.footnotes&.footnote_entries,
137
198
  package.endnotes&.endnote_entries,
138
199
  )
200
+ alloc.seed_from_comments(comment_entries_of(package))
139
201
  alloc
140
202
  end
203
+
204
+ # Comment entries of a package or document, tolerating the legacy
205
+ # Array form of DocumentRoot#comments.
206
+ def self.comment_entries_of(source)
207
+ part = source.comments
208
+ part.is_a?(Uniword::CommentsPart) ? part.comments : part
209
+ end
210
+ private_class_method :comment_entries_of
211
+
212
+ private
213
+
214
+ # Seed one relationship (see seed_from_rels for the collision rule).
215
+ def seed_rel(rel, scope)
216
+ key = [scope, rel.target, rel.type.to_s]
217
+ return if @rid_entries.key?(key)
218
+
219
+ if rid_id_taken?(rel.id, scope)
220
+ alloc_rid(target: rel.target, type: rel.type,
221
+ target_mode: rel.target_mode, scope: scope)
222
+ return
223
+ end
224
+
225
+ register_rid(rel.id, target: rel.target, type: rel.type,
226
+ target_mode: rel.target_mode, scope: scope)
227
+ end
228
+
229
+ # Whether an rId string is already registered within a scope.
230
+ def rid_id_taken?(id, scope)
231
+ @rid_entries.any? do |key, entry|
232
+ key[0] == scope && entry[:id] == id
233
+ end
234
+ end
141
235
  end
142
236
  end
143
237
  end