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
@@ -7,34 +7,35 @@ module Uniword
7
7
  #
8
8
  # Rebuilds content types and relationships for standard parts,
9
9
  # preserving non-standard entries from the source document.
10
+ # All part metadata (paths, content types, rel types) comes from
11
+ # Ooxml::PartRegistry; this module only declares which parts go
12
+ # where, in which order.
10
13
  module PackageStructure
11
14
  # Relationship types for parts we don't model or serialize.
15
+ # Exception: stylesWithEffects is a Microsoft extension never
16
+ # written by uniword, so it is not in Ooxml::PartRegistry.
12
17
  UNSUPPORTED_REL_TYPES = Set[
13
18
  "http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects",
14
19
  ].freeze
15
20
 
16
21
  # Rel types that belong in package-level _rels/.rels, not document.xml.rels.
17
- PACKAGE_LEVEL_REL_TYPES = Set[
18
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
19
- "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties",
20
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
21
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
22
- ].freeze
22
+ PACKAGE_LEVEL_REL_TYPES =
23
+ Ooxml::PartRegistry.package_rel_types.to_set.freeze
24
+
25
+ # Standard package-level parts, in _rels/.rels emission order.
26
+ PACKAGE_REL_PARTS = %i[document core_properties app_properties].freeze
23
27
 
24
28
  def reconcile_content_types
25
29
  ct = package.content_types
26
30
  return unless ct
27
31
 
28
- ct.defaults = [
32
+ ct.defaults = %i[rels xml].map do |key|
33
+ defn = Ooxml::PartRegistry.find_by_key(key)
29
34
  Uniword::ContentTypes::Default.new(
30
- extension: "rels",
31
- content_type: "application/vnd.openxmlformats-package.relationships+xml",
32
- ),
33
- Uniword::ContentTypes::Default.new(
34
- extension: "xml",
35
- content_type: "application/xml",
36
- ),
37
- ]
35
+ extension: defn.extension,
36
+ content_type: defn.content_type,
37
+ )
38
+ end
38
39
 
39
40
  standard = content_type_overrides_for_present_parts
40
41
  standard_parts = standard.to_set(&:part_name)
@@ -43,202 +44,170 @@ module Uniword
43
44
  end
44
45
 
45
46
  ct.overrides = standard + non_standard
46
- record_fix(FixCodes::CONTENT_TYPES_ASSEMBLED, "Rebuilt content types for standard parts")
47
+ record_fix(FixCodes::CONTENT_TYPES_ASSEMBLED,
48
+ "Rebuilt content types for standard parts",
49
+ part: "[Content_Types].xml")
47
50
  end
48
51
 
49
52
  def reconcile_package_rels
50
53
  rels = package.package_rels
51
54
  return unless rels
52
55
 
53
- base = "http://schemas.openxmlformats.org"
54
- standard_defs = [
55
- ["rId1",
56
- "#{base}/officeDocument/2006/relationships/officeDocument",
57
- "word/document.xml"],
58
- ["rId2",
59
- "#{base}/package/2006/relationships/metadata/core-properties",
60
- "docProps/core.xml"],
61
- ["rId3",
62
- "#{base}/officeDocument/2006/relationships/extended-properties",
63
- "docProps/app.xml"],
64
- ]
65
-
66
- standard_targets = standard_defs.to_set { |_, _, t| t }
67
- standard_rids = standard_defs.to_set { |rid, _, _| rid }
68
- non_standard = rels.relationships.reject do |r|
69
- standard_targets.include?(r.target) || standard_rids.include?(r.id)
56
+ # Preserve-first: existing rels keep their position and rId;
57
+ # only genuinely missing standard parts are appended, with
58
+ # rIds allocated by the allocator (seeded from these rels).
59
+ # A later rel whose rId duplicates an already-kept one is
60
+ # dropped — duplicates are impossible by construction.
61
+ kept = []
62
+ seen_ids = Set.new
63
+ rels.relationships.each do |rel|
64
+ next if rel.id && seen_ids.include?(rel.id)
65
+
66
+ kept << rel
67
+ seen_ids << rel.id if rel.id
70
68
  end
71
69
 
72
- existing_by_target = rels.relationships.to_h { |r| [r.target, r] }
73
- standard = standard_defs.map do |rid, type, target|
74
- existing = existing_by_target[target]
75
- build_rel(existing ? existing.id : rid, type, target)
70
+ PACKAGE_REL_PARTS.each do |key|
71
+ defn = Ooxml::PartRegistry.find_by_key(key)
72
+ next if kept.any? { |r| r.target == defn.target }
73
+
74
+ kept << build_rel(
75
+ allocator.alloc_rid(target: defn.target, type: defn.rel_type,
76
+ scope: :package),
77
+ defn.rel_type, defn.target,
78
+ )
76
79
  end
77
80
 
78
- rels.relationships = standard + non_standard
79
- record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Rebuilt package relationships for standard parts")
81
+ rels.relationships = kept
82
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
83
+ "Assembled package relationships (preserve-first)",
84
+ part: "_rels/.rels")
80
85
  end
81
86
 
82
87
  def reconcile_document_rels
83
88
  rels = package.document_rels
84
89
  return unless rels
85
90
 
86
- base = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
87
- defs = [
88
- ["styles", "styles.xml", package.styles],
89
- ["settings", "settings.xml", package.settings],
90
- ["webSettings", "webSettings.xml", package.web_settings],
91
- ["fontTable", "fontTable.xml", package.font_table],
92
- ["theme", "theme/theme1.xml", package.theme],
93
- ["numbering", "numbering.xml", package.numbering],
94
- ["footnotes", "footnotes.xml", package.footnotes],
95
- ["endnotes", "endnotes.xml", package.endnotes],
96
- ]
97
-
98
- standard_targets = defs.filter_map { |_, target, obj| target if obj }.to_set
99
-
100
- # If allocator is present, use it to build rels — preserves existing rIds
101
- alloc = allocator
102
- if alloc
103
- reconcile_document_rels_from_allocator(rels, base, defs, standard_targets, alloc)
104
- else
105
- reconcile_document_rels_legacy(rels, base, defs, standard_targets)
106
- end
91
+ assemble_document_rels(rels, document_rel_defs)
107
92
  end
108
93
 
109
94
  private
110
95
 
111
- def reconcile_document_rels_from_allocator(rels, base, defs, standard_targets, alloc)
112
- # Collect standard part rels from allocator
113
- all_rels = []
114
- defs.each do |suffix, target, obj|
115
- next unless obj
116
- r_id = alloc.rid_for(target: target, type: "#{base}/#{suffix}")
117
- if r_id
118
- all_rels << build_rel(r_id, "#{base}/#{suffix}", target)
119
- else
120
- all_rels << build_rel(
121
- alloc.alloc_rid(target: target, type: "#{base}/#{suffix}"),
122
- "#{base}/#{suffix}", target,
123
- )
124
- end
125
- end
126
-
127
- # Add allocator-managed rels (images, headers, footers, hyperlinks)
128
- alloc.all_rels.each do |entry|
129
- next if standard_targets.include?(entry[:target])
130
- next if all_rels.any? { |r| r.target == entry[:target] }
131
- next if unsupported_rel_type?(entry[:type])
132
- next if package_level_rel?(entry[:type])
133
- next unless header_footer_target_present?(entry[:target])
134
-
135
- all_rels << build_rel(
136
- entry[:id], entry[:type], entry[:target],
137
- target_mode: entry[:target_mode],
138
- )
139
- end
96
+ # [PartDefinition, package part] pairs for document-level parts
97
+ # that must carry a relationship when present, in emission order.
98
+ def document_rel_defs
99
+ defs = [
100
+ [Ooxml::PartRegistry.find_by_key(:styles), package.styles],
101
+ [Ooxml::PartRegistry.find_by_key(:settings), package.settings],
102
+ [Ooxml::PartRegistry.find_by_key(:web_settings),
103
+ package.web_settings],
104
+ [Ooxml::PartRegistry.find_by_key(:font_table),
105
+ package.font_table],
106
+ [Ooxml::PartRegistry.find_by_key(:theme), package.theme],
107
+ [Ooxml::PartRegistry.find_by_key(:numbering), package.numbering],
108
+ [Ooxml::PartRegistry.find_by_key(:footnotes), package.footnotes],
109
+ [Ooxml::PartRegistry.find_by_key(:endnotes), package.endnotes],
110
+ [Ooxml::PartRegistry.find_by_key(:comments), package.comments],
111
+ ]
140
112
 
141
- # Preserve non-standard rels not managed by allocator
142
- existing_targets = all_rels.to_set(&:target)
143
- non_standard = rels.relationships.reject do |r|
144
- existing_targets.include?(r.target) ||
145
- standard_targets.include?(r.target) ||
146
- unsupported_rel_type?(r.type) ||
147
- package_level_rel?(r.type) ||
148
- !header_footer_target_present?(r.target)
113
+ sources = package.bibliography_sources ||
114
+ package.document&.bibliography_sources
115
+ if sources
116
+ defs << [Ooxml::PartRegistry.find_by_key(:bibliography), sources]
149
117
  end
150
118
 
151
- rels.relationships = all_rels + non_standard
152
- record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Assembled document relationships from allocator")
119
+ defs
153
120
  end
154
121
 
155
- def reconcile_document_rels_legacy(rels, base, defs, standard_targets)
156
- non_standard = rels.relationships.reject do |r|
157
- standard_targets.include?(r.target) ||
158
- unsupported_rel_type?(r.type) ||
159
- package_level_rel?(r.type) ||
160
- !header_footer_target_present?(r.target)
161
- end
162
-
163
- all_rels = []
164
- rid_mapping = {}
165
-
166
- defs.each do |suffix, target, obj|
167
- next unless obj
168
- rid = "rId#{all_rels.size + 1}"
169
- all_rels << build_rel(rid, "#{base}/#{suffix}", target)
170
- end
122
+ # Preserve-first assembly: existing relationships keep their
123
+ # position and rId (the allocator was seeded from them), new
124
+ # standard parts and new allocator-registered entries (images,
125
+ # hyperlinks, headers/footers added at build time) are appended
126
+ # with freshly allocated ids. No renumbering, ever.
127
+ def assemble_document_rels(rels, defs)
128
+ alloc = allocator
129
+ register_auxiliary_part_rels(alloc)
130
+ kept = kept_existing_relationships(rels, alloc)
131
+ kept_targets = kept.to_set(&:target)
171
132
 
172
- non_standard.each do |rel|
173
- old_rid = rel.id
174
- new_rid = "rId#{all_rels.size + 1}"
175
- rid_mapping[old_rid] = new_rid if old_rid != new_rid
176
- all_rels << build_rel(new_rid, rel.type, rel.target,
177
- target_mode: rel.target_mode)
178
- end
133
+ append_missing_standard_rels(kept, kept_targets, defs, alloc)
134
+ append_allocator_rels(kept, kept_targets, alloc)
179
135
 
180
- rels.relationships = all_rels
181
- update_sect_pr_rid_references(rid_mapping) unless rid_mapping.empty?
182
- update_blip_embed_references(rid_mapping) unless rid_mapping.empty?
183
- update_hyperlink_rid_references(rid_mapping) unless rid_mapping.empty?
184
- record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED, "Rebuilt document relationships with sequential rIds")
136
+ rels.relationships = kept
137
+ record_fix(FixCodes::RELATIONSHIPS_ASSEMBLED,
138
+ "Assembled document relationships (preserve-first)",
139
+ part: "word/_rels/document.xml.rels")
185
140
  end
186
141
 
187
- def update_sect_pr_rid_references(mapping)
188
- sect_pr = package.document&.body&.section_properties
189
- return unless sect_pr
190
-
191
- [sect_pr.header_references, sect_pr.footer_references].each do |refs|
192
- next unless refs
193
-
194
- refs.each do |ref|
195
- new_rid = mapping[ref.r_id]
196
- ref.r_id = new_rid if new_rid
197
- end
142
+ # Existing rels that survive the assembly, in original order:
143
+ # drops unsupported and package-level types and stale
144
+ # header/footer targets; everything else keeps its rId verbatim
145
+ # (or the allocator's reallocated id when seeding resolved a
146
+ # collision for this target+type).
147
+ def kept_existing_relationships(rels, alloc)
148
+ used_ids = Set.new
149
+ rels.relationships.filter_map do |rel|
150
+ next if unsupported_rel_type?(rel.type)
151
+ next if package_level_rel?(rel.type)
152
+ next unless header_footer_target_present?(rel.target)
153
+
154
+ rid = alloc.rid_for(target: rel.target, type: rel.type)
155
+ rid = rel.id if rid.nil? || used_ids.include?(rid)
156
+ next if rid && used_ids.include?(rid)
157
+
158
+ used_ids << rid if rid
159
+ build_rel(rid, rel.type, rel.target,
160
+ target_mode: rel.target_mode)
198
161
  end
199
162
  end
200
163
 
201
- def update_blip_embed_references(mapping)
202
- paragraphs = package.document&.body&.paragraphs
203
- return unless paragraphs
204
-
205
- paragraphs.each do |para|
206
- next unless para.runs
164
+ # Register rels for package-carried parts outside the standard
165
+ # defs: OLE/embedded binaries and chart parts. Loaded packages
166
+ # seeded these already (no-op); programmatically added parts
167
+ # get freshly allocated ids from the allocator.
168
+ def register_auxiliary_part_rels(alloc)
169
+ ole = Ooxml::PartRegistry.find_by_key(:ole_object)
170
+ (package.embeddings&.keys || []).each do |target|
171
+ alloc.alloc_rid(target: target, type: ole.rel_type)
172
+ end
207
173
 
208
- para.runs.each do |run|
209
- next unless run.drawings
174
+ chart = Ooxml::PartRegistry.find_by_key(:chart)
175
+ (package.document&.chart_parts&.values || []).each do |data|
176
+ next unless data[:target]
210
177
 
211
- run.drawings.each do |drawing|
212
- update_drawing_blip(drawing, mapping)
213
- end
214
- end
178
+ alloc.alloc_rid(target: data[:target], type: chart.rel_type)
215
179
  end
216
180
  end
217
181
 
218
- def update_drawing_blip(drawing, mapping)
219
- graphic = drawing.inline&.graphic || drawing.anchor&.graphic
220
- return unless graphic
221
-
222
- picture = graphic.graphic_data&.picture
223
- return unless picture
224
-
225
- blip = picture.blip_fill&.blip
226
- return unless blip&.embed
182
+ # Append rels for present standard parts that have no
183
+ # relationship yet (fresh or newly added parts).
184
+ def append_missing_standard_rels(kept, kept_targets, defs, alloc)
185
+ defs.each do |defn, obj|
186
+ next unless obj
187
+ next if kept_targets.include?(defn.target)
227
188
 
228
- new_rid = mapping[blip.embed.to_s]
229
- blip.embed = new_rid if new_rid
189
+ kept << build_rel(
190
+ alloc.alloc_rid(target: defn.target, type: defn.rel_type),
191
+ defn.rel_type, defn.target,
192
+ )
193
+ kept_targets << defn.target
194
+ end
230
195
  end
231
196
 
232
- def update_hyperlink_rid_references(mapping)
233
- body = package.document&.body
234
- return unless body
197
+ # Append allocator-registered rels not already present —
198
+ # builder-added images, hyperlinks, charts, headers/footers.
199
+ def append_allocator_rels(kept, kept_targets, alloc)
200
+ alloc.all_rels(scope: :document).each do |entry|
201
+ next if kept_targets.include?(entry[:target])
202
+ next if unsupported_rel_type?(entry[:type])
203
+ next if package_level_rel?(entry[:type])
204
+ next unless header_footer_target_present?(entry[:target])
235
205
 
236
- walk_body_paragraphs(body) do |para|
237
- (para.hyperlinks || []).each do |hl|
238
- next unless hl.id
239
- new_rid = mapping[hl.id.to_s]
240
- hl.id = new_rid if new_rid
241
- end
206
+ kept << build_rel(
207
+ entry[:id], entry[:type], entry[:target],
208
+ target_mode: entry[:target_mode],
209
+ )
210
+ kept_targets << entry[:target]
242
211
  end
243
212
  end
244
213
 
@@ -250,69 +219,48 @@ module Uniword
250
219
  PACKAGE_LEVEL_REL_TYPES.include?(type.to_s)
251
220
  end
252
221
 
253
- # Check if a header/footer rel target corresponds to a serialized part.
254
- # Headers/footers may be in two places:
255
- # - document.headers / document.footers (single-section hash)
256
- # - document.header_footer_parts (multi-section ordered array)
257
- # Headers/footers are numbered sequentially: header1.xml, header2.xml...
258
- # If the model has fewer headers/footers than the target index, it's stale.
222
+ # Check if a header/footer rel target corresponds to a part the
223
+ # package will emit. All header/footer parts live in the unified
224
+ # store (document.header_footer_parts), whether loaded from the
225
+ # source package or added programmatically; a rel whose target
226
+ # is not in the store points at a part that will not be
227
+ # serialized and must be treated as stale.
259
228
  def header_footer_target_present?(target)
260
- if target.start_with?("header") && target.end_with?(".xml")
261
- num = target[/header(\d+)\.xml/, 1]&.to_i
262
- hash_count = package.document&.headers&.size || 0
263
- parts_count = count_parts_matching("header")
264
- count = [hash_count, parts_count].max
265
- return num && num <= count
266
- end
267
- if target.start_with?("footer") && target.end_with?(".xml")
268
- num = target[/footer(\d+)\.xml/, 1]&.to_i
269
- hash_count = package.document&.footers&.size || 0
270
- parts_count = count_parts_matching("footer")
271
- count = [hash_count, parts_count].max
272
- return num && num <= count
229
+ header_like = target.start_with?("header") || target.start_with?("footer")
230
+ if header_like && target.end_with?(".xml")
231
+ parts = package.document&.header_footer_parts
232
+ return false unless parts
233
+
234
+ return parts.targets.include?(target)
273
235
  end
274
236
  true
275
237
  end
276
238
 
277
- def count_parts_matching(prefix)
278
- parts = package.document&.header_footer_parts
279
- return 0 unless parts
280
-
281
- parts.count { |p| p[:target].to_s.start_with?(prefix) }
282
- end
283
-
239
+ # [registry key, package part] pairs in content-types emission
240
+ # order; overrides are derived from Ooxml::PartRegistry.
284
241
  def content_type_overrides_for_present_parts
285
242
  checks = [
286
- [package.document, "/word/document.xml",
287
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"],
288
- [package.styles, "/word/styles.xml",
289
- "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"],
290
- [package.settings, "/word/settings.xml",
291
- "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"],
292
- [package.font_table, "/word/fontTable.xml",
293
- "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"],
294
- [package.web_settings, "/word/webSettings.xml",
295
- "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"],
296
- [package.theme, "/word/theme/theme1.xml",
297
- "application/vnd.openxmlformats-officedocument.theme+xml"],
298
- [package.core_properties, "/docProps/core.xml",
299
- "application/vnd.openxmlformats-package.core-properties+xml"],
300
- [package.app_properties, "/docProps/app.xml",
301
- "application/vnd.openxmlformats-officedocument.extended-properties+xml"],
302
- [package.footnotes, "/word/footnotes.xml",
303
- "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"],
304
- [package.endnotes, "/word/endnotes.xml",
305
- "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"],
306
- [package.numbering, "/word/numbering.xml",
307
- "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"],
243
+ [:document, package.document],
244
+ [:styles, package.styles],
245
+ [:settings, package.settings],
246
+ [:font_table, package.font_table],
247
+ [:web_settings, package.web_settings],
248
+ [:theme, package.theme],
249
+ [:core_properties, package.core_properties],
250
+ [:app_properties, package.app_properties],
251
+ [:footnotes, package.footnotes],
252
+ [:endnotes, package.endnotes],
253
+ [:comments, package.comments],
254
+ [:numbering, package.numbering],
308
255
  ]
309
256
 
310
- checks.filter_map do |obj, part_name, content_type|
257
+ checks.filter_map do |key, obj|
311
258
  next unless obj
312
259
 
260
+ defn = Ooxml::PartRegistry.find_by_key(key)
313
261
  Uniword::ContentTypes::Override.new(
314
- part_name: part_name,
315
- content_type: content_type,
262
+ part_name: defn.part_name,
263
+ content_type: defn.content_type,
316
264
  )
317
265
  end
318
266
  end
@@ -50,11 +50,19 @@ module Uniword
50
50
  val: profile.list_separator,
51
51
  )
52
52
 
53
+ # w:updateFields set by the user/API: ensure it serializes —
54
+ # parsed settings carry a stored element_order that lacks it.
55
+ if settings.update_fields
56
+ ensure_element_in_order(settings, "updateFields",
57
+ after: "characterSpacingControl")
58
+ end
59
+
53
60
  unless settings.w14_doc_id
54
61
  settings.w14_doc_id = Wordprocessingml::W14DocId.new(
55
62
  val: hex_derive("w14_doc_id", 4),
56
63
  )
57
- record_fix(FixCodes::DOC_ID_GENERATED, "Generated w14:docId")
64
+ record_fix(FixCodes::DOC_ID_GENERATED, "Generated w14:docId",
65
+ part: "word/settings.xml")
58
66
  end
59
67
  unless settings.w15_doc_id
60
68
  raw = hex_derive("w15_doc_id", 16)
@@ -63,7 +71,9 @@ module Uniword
63
71
  settings.w15_doc_id = Wordprocessingml::W15DocId.new(
64
72
  val: "{#{formatted.upcase}}",
65
73
  )
66
- record_fix(FixCodes::DOC_ID_GENERATED, "Generated w15:docId in GUID format")
74
+ record_fix(FixCodes::DOC_ID_GENERATED,
75
+ "Generated w15:docId in GUID format",
76
+ part: "word/settings.xml")
67
77
  end
68
78
 
69
79
  set_mc_ignorable(settings)
@@ -75,7 +85,8 @@ module Uniword
75
85
  font_table = package.font_table
76
86
  font_table ||= begin
77
87
  package.font_table = Wordprocessingml::FontTable.new
78
- record_fix(FixCodes::FONT_TABLE_CREATED, "Created font table")
88
+ record_fix(FixCodes::FONT_TABLE_CREATED, "Created font table",
89
+ part: "word/fontTable.xml")
79
90
  package.font_table
80
91
  end
81
92
 
@@ -114,7 +125,8 @@ module Uniword
114
125
 
115
126
  set_mc_ignorable(font_table)
116
127
  record_fix(FixCodes::FONT_TABLE_CREATED,
117
- "Populated font table with profile fonts and signatures")
128
+ "Populated font table with profile fonts and signatures",
129
+ part: "word/fontTable.xml")
118
130
  end
119
131
 
120
132
  def reconcile_styles
@@ -133,7 +145,8 @@ module Uniword
133
145
 
134
146
  set_mc_ignorable(styles)
135
147
  record_fix(FixCodes::STYLE_DEFAULTS_ADDED,
136
- "Ensured styles have docDefaults, latentStyles, and default styles")
148
+ "Ensured styles have docDefaults, latentStyles, and default styles",
149
+ part: "word/styles.xml")
137
150
  end
138
151
 
139
152
  def reconcile_numbering
@@ -149,7 +162,8 @@ module Uniword
149
162
  raw = raw - 0x100000000 if raw >= 0x80000000
150
163
  inst.durable_id = raw.to_s
151
164
  record_fix(FixCodes::NUMBERING_REFERENCED,
152
- "Generated w16cid:durableId for numId=#{inst.num_id}")
165
+ "Generated w16cid:durableId for numId=#{inst.num_id}",
166
+ part: "word/numbering.xml")
153
167
  end
154
168
 
155
169
  package.numbering.instances.each do |inst|
@@ -165,8 +179,10 @@ module Uniword
165
179
  end
166
180
  next if defn
167
181
 
168
- record_fix(FixCodes::NUMBERING_REFERENCED, "Numbering instance numId=#{inst.num_id} references " \
169
- "missing abstractNumId=#{abs_id}")
182
+ record_fix(FixCodes::NUMBERING_REFERENCED,
183
+ "Numbering instance numId=#{inst.num_id} references " \
184
+ "missing abstractNumId=#{abs_id}",
185
+ part: "word/numbering.xml")
170
186
  end
171
187
  end
172
188
 
@@ -180,7 +196,8 @@ module Uniword
180
196
  end
181
197
 
182
198
  set_mc_ignorable(ws)
183
- record_fix(FixCodes::MC_IGNORABLE, "Cleared mc:Ignorable on webSettings")
199
+ record_fix(FixCodes::MC_IGNORABLE, "Cleared mc:Ignorable on webSettings",
200
+ part: "word/webSettings.xml")
184
201
  end
185
202
 
186
203
  def reconcile_app_properties
@@ -219,7 +236,9 @@ module Uniword
219
236
  app.heading_pairs = nil
220
237
  app.titles_of_parts = nil
221
238
 
222
- record_fix(FixCodes::APP_PROPERTIES_ENSURED, "Ensured app properties with statistics")
239
+ record_fix(FixCodes::APP_PROPERTIES_ENSURED,
240
+ "Ensured app properties with statistics",
241
+ part: "docProps/app.xml")
223
242
  end
224
243
 
225
244
  def reconcile_core_properties
@@ -259,7 +278,9 @@ module Uniword
259
278
  )
260
279
 
261
280
  cp.revision = "1" unless cp.revision
262
- record_fix(FixCodes::CORE_PROPERTIES_REBUILT, "Rebuilt core properties with namespace declarations")
281
+ record_fix(FixCodes::CORE_PROPERTIES_REBUILT,
282
+ "Rebuilt core properties with namespace declarations",
283
+ part: "docProps/core.xml")
263
284
  end
264
285
 
265
286
  private
@@ -421,10 +442,12 @@ module Uniword
421
442
  end
422
443
 
423
444
  dpf = styles.styles.find { |s| s.id == "DefaultParagraphFont" }
424
- if dpf && !dpf.semiHidden && !allocator
445
+ if dpf && !dpf.semiHidden && !builder_managed?
425
446
  dpf.semiHidden = Wordprocessingml::SemiHidden.new
426
447
  ensure_element_in_order(dpf, "semiHidden", after: "uiPriority")
427
- record_fix(FixCodes::SEMI_HIDDEN_ADDED, "Added semiHidden to DefaultParagraphFont style")
448
+ record_fix(FixCodes::SEMI_HIDDEN_ADDED,
449
+ "Added semiHidden to DefaultParagraphFont style",
450
+ part: "word/styles.xml")
428
451
  end
429
452
 
430
453
  unless style_ids.include?("TableNormal")