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
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # Ordered collection of HeaderFooterPart objects — the single
6
+ # storage path for all header/footer parts of a document, whether
7
+ # loaded from an existing DOCX or added programmatically.
8
+ #
9
+ # The +headers+/+footers+ views (HeaderFooterView) and the
10
+ # serializer both read from this store, so a part exists exactly
11
+ # once: one part file, one relationship, one content-type
12
+ # override, one sectPr reference.
13
+ class HeaderFooterPartCollection
14
+ include Enumerable
15
+
16
+ def initialize
17
+ @parts = []
18
+ end
19
+
20
+ # Iterate over all parts (both kinds) in insertion order.
21
+ def each(&block)
22
+ @parts.each(&block)
23
+ end
24
+
25
+ # @return [Integer]
26
+ def size
27
+ @parts.size
28
+ end
29
+
30
+ alias length size
31
+
32
+ # @return [Boolean]
33
+ def empty?
34
+ @parts.empty?
35
+ end
36
+
37
+ # @return [Array<HeaderFooterPart>]
38
+ def to_a
39
+ @parts.dup
40
+ end
41
+
42
+ # Append a part.
43
+ #
44
+ # @param part [HeaderFooterPart]
45
+ # @return [HeaderFooterPart] the added part
46
+ def <<(part)
47
+ @parts << part
48
+ part
49
+ end
50
+
51
+ alias add <<
52
+
53
+ # Remove a part (by identity).
54
+ #
55
+ # @param part [HeaderFooterPart]
56
+ # @return [HeaderFooterPart, nil] the removed part
57
+ def delete(part)
58
+ @parts.delete(part)
59
+ end
60
+
61
+ # @param kind [Symbol] :header or :footer
62
+ # @return [Array<HeaderFooterPart>] parts of that kind, in order
63
+ def of_kind(kind)
64
+ @parts.select { |part| part.kind == kind }
65
+ end
66
+
67
+ # @param kind [Symbol] :header or :footer
68
+ # @param type [String, nil] sectPr reference type
69
+ # @return [HeaderFooterPart, nil]
70
+ def find_part(kind, type)
71
+ type = type&.to_s
72
+ @parts.find { |part| part.kind == kind && part.type == type }
73
+ end
74
+
75
+ # All non-nil relationship targets in the store.
76
+ #
77
+ # @return [Array<String>]
78
+ def targets
79
+ @parts.filter_map(&:target)
80
+ end
81
+
82
+ # First unused numbered target for a kind
83
+ # ("header3.xml" when header1/header2 exist).
84
+ #
85
+ # @param kind [Symbol] :header or :footer
86
+ # @return [String]
87
+ def next_target(kind)
88
+ numbers = of_kind(kind).filter_map do |part|
89
+ part.target&.[](/\A#{kind}(\d+)\.xml\z/, 1)&.to_i
90
+ end
91
+ "#{kind}#{(numbers.max || 0) + 1}.xml"
92
+ end
93
+
94
+ # Replace the entire store (legacy +header_footer_parts=+
95
+ # assignment). Accepts HeaderFooterPart objects and legacy
96
+ # part hashes ({ r_id:, target:, rel_type:, content_type:,
97
+ # content: }).
98
+ #
99
+ # @param parts [Array<HeaderFooterPart, Hash>]
100
+ # @return [void]
101
+ def replace_all(parts)
102
+ @parts = Array(parts).map { |part| self.class.wrap(part) }
103
+ end
104
+
105
+ # Replace all parts of one kind, leaving the other untouched.
106
+ #
107
+ # @param kind [Symbol] :header or :footer
108
+ # @param parts [Array<HeaderFooterPart>]
109
+ # @return [void]
110
+ def replace_kind(kind, parts)
111
+ @parts.reject! { |part| part.kind == kind }
112
+ parts.each { |part| @parts << part }
113
+ end
114
+
115
+ # Normalize one entry into a HeaderFooterPart. Legacy hashes
116
+ # describe loaded parts (they carry original rIds/targets).
117
+ #
118
+ # @param value [HeaderFooterPart, Hash]
119
+ # @return [HeaderFooterPart]
120
+ def self.wrap(value)
121
+ return value if value.is_a?(HeaderFooterPart)
122
+
123
+ HeaderFooterPart.new(
124
+ r_id: value[:r_id],
125
+ target: value[:target],
126
+ rel_type: value[:rel_type],
127
+ content_type: value[:content_type],
128
+ type: value[:type],
129
+ content: value[:content],
130
+ loaded: true,
131
+ )
132
+ end
133
+ end
134
+ end
135
+ end
@@ -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
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # An image part (word/media/*) held by the package: binary image
6
+ # data plus the packaging metadata needed to emit it (relationship
7
+ # target, relationship id, content type).
8
+ #
9
+ # The image content type is per-file (resolved from the file
10
+ # extension), so it is always carried explicitly — the registry's
11
+ # :image definition contributes only the relationship type.
12
+ #
13
+ # Replaces the raw +{ data:, target:, content_type:, path: }+ hash
14
+ # entries formerly stored in +DocumentRoot#image_parts+. Hash-style
15
+ # read access (+part[:data]+, +part[:target]+, +part[:content_type]+,
16
+ # +part[:path]+) is kept for backward compatibility.
17
+ #
18
+ # @example
19
+ # part = ImagePart.new(
20
+ # r_id: "rId5", target: "media/image1.png",
21
+ # data: binary_data, content_type: "image/png",
22
+ # )
23
+ # part.data # => binary image data
24
+ # part.content_type # => "image/png"
25
+ class ImagePart < Part
26
+ # @return [String, nil] path of the source file the image was
27
+ # read from (builder-added images only; nil for loaded parts)
28
+ attr_accessor :source_path
29
+
30
+ # @param r_id [String, nil] relationship id
31
+ # @param target [String, nil] e.g. "media/image1.png"
32
+ # @param data [String, nil] binary image data
33
+ # @param content_type [String, nil] e.g. "image/png"
34
+ # @param source_path [String, nil] on-disk source path
35
+ # rubocop:disable Metrics/ParameterLists
36
+ def initialize(r_id: nil, target: nil, data: nil, content_type: nil,
37
+ source_path: nil, **rest)
38
+ super(
39
+ definition: Ooxml::PartRegistry.find_by_key(:image),
40
+ r_id: r_id, target: target, content: data,
41
+ content_type: content_type, **rest
42
+ )
43
+ @source_path = source_path
44
+ end
45
+ # rubocop:enable Metrics/ParameterLists
46
+
47
+ # Wrap a legacy raw-hash entry ({ data:, target:, content_type:,
48
+ # path: }) into an ImagePart.
49
+ #
50
+ # @param hash [Hash] legacy hash entry
51
+ # @return [ImagePart]
52
+ def self.from_hash(hash)
53
+ new(target: hash[:target], data: hash[:data],
54
+ content_type: hash[:content_type], source_path: hash[:path])
55
+ end
56
+
57
+ # Binary image data (alias for content).
58
+ #
59
+ # @return [String, nil]
60
+ def data
61
+ content
62
+ end
63
+
64
+ # Set the binary image data (alias for content=).
65
+ #
66
+ # @param value [String, nil] binary image data
67
+ # @return [void]
68
+ def data=(value)
69
+ self.content = value
70
+ end
71
+
72
+ # Hash-style read compatibility (+:data+ and the legacy +:path+
73
+ # source path in addition to the Part keys).
74
+ def [](key)
75
+ case key.to_sym
76
+ when :data then content
77
+ when :path then source_path
78
+ else super
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end