uniword 1.0.11 → 1.2.5

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 (211) hide show
  1. checksums.yaml +4 -4
  2. data/config/font_metadata.yml +56 -0
  3. data/lib/uniword/accessibility/rules/descriptive_headings_rule.rb +1 -1
  4. data/lib/uniword/assembly/toc.rb +4 -14
  5. data/lib/uniword/assembly/variable_substitutor.rb +3 -4
  6. data/lib/uniword/batch/stages/normalize_styles_stage.rb +4 -2
  7. data/lib/uniword/builder/base_builder.rb +25 -0
  8. data/lib/uniword/builder/chart_builder.rb +35 -14
  9. data/lib/uniword/builder/deterministic_id.rb +17 -0
  10. data/lib/uniword/builder/document_builder.rb +129 -34
  11. data/lib/uniword/builder/footnote_builder.rb +22 -23
  12. data/lib/uniword/builder/has_borders.rb +8 -1
  13. data/lib/uniword/builder/header_footer_builder.rb +10 -10
  14. data/lib/uniword/builder/image_builder.rb +24 -16
  15. data/lib/uniword/builder/list_builder.rb +2 -1
  16. data/lib/uniword/builder/paragraph_builder.rb +95 -20
  17. data/lib/uniword/builder/run_utils.rb +97 -0
  18. data/lib/uniword/builder/section_builder.rb +40 -67
  19. data/lib/uniword/builder/table_builder.rb +32 -13
  20. data/lib/uniword/builder/table_cell_builder.rb +19 -0
  21. data/lib/uniword/builder.rb +27 -19
  22. data/lib/uniword/cli/diff_cli.rb +0 -1
  23. data/lib/uniword/cli/generate_cli.rb +0 -1
  24. data/lib/uniword/cli/headers_cli.rb +0 -1
  25. data/lib/uniword/cli/images_cli.rb +0 -1
  26. data/lib/uniword/cli/main.rb +6 -20
  27. data/lib/uniword/cli/protect_cli.rb +0 -1
  28. data/lib/uniword/cli/resources_cli.rb +0 -1
  29. data/lib/uniword/cli/review_cli.rb +0 -1
  30. data/lib/uniword/cli/spellcheck_cli.rb +0 -1
  31. data/lib/uniword/cli/styleset_cli.rb +0 -1
  32. data/lib/uniword/cli/template_cli.rb +0 -1
  33. data/lib/uniword/cli/theme_cli.rb +0 -1
  34. data/lib/uniword/cli/toc_cli.rb +0 -1
  35. data/lib/uniword/cli/watermark_cli.rb +0 -1
  36. data/lib/uniword/cli.rb +6 -17
  37. data/lib/uniword/diff/document_differ.rb +23 -5
  38. data/lib/uniword/diff/package_differ.rb +0 -1
  39. data/lib/uniword/diff.rb +4 -0
  40. data/lib/uniword/document_factory.rb +16 -25
  41. data/lib/uniword/docx/document_statistics.rb +42 -54
  42. data/lib/uniword/docx/id_allocator.rb +143 -0
  43. data/lib/uniword/docx/package.rb +42 -6
  44. data/lib/uniword/docx/package_defaults.rb +15 -25
  45. data/lib/uniword/docx/package_serialization.rb +141 -127
  46. data/lib/uniword/docx/reconciler/body.rb +197 -0
  47. data/lib/uniword/docx/reconciler/fix_codes.rb +74 -0
  48. data/lib/uniword/docx/reconciler/helpers.rb +337 -0
  49. data/lib/uniword/docx/reconciler/notes.rb +301 -0
  50. data/lib/uniword/docx/reconciler/package_structure.rb +322 -0
  51. data/lib/uniword/docx/reconciler/parts.rb +487 -0
  52. data/lib/uniword/docx/reconciler/referential_integrity.rb +449 -0
  53. data/lib/uniword/docx/reconciler/tables.rb +162 -0
  54. data/lib/uniword/docx/reconciler/theme.rb +141 -0
  55. data/lib/uniword/docx/reconciler.rb +46 -1096
  56. data/lib/uniword/docx.rb +3 -0
  57. data/lib/uniword/drawingml/color_scheme.rb +37 -5
  58. data/lib/uniword/drawingml/theme.rb +0 -1
  59. data/lib/uniword/hyperlink.rb +27 -26
  60. data/lib/uniword/infrastructure/zip_packager.rb +41 -38
  61. data/lib/uniword/lazy_loader.rb +36 -62
  62. data/lib/uniword/math.rb +3 -86
  63. data/lib/uniword/mhtml/document.rb +1 -3
  64. data/lib/uniword/model_attribute_access.rb +27 -0
  65. data/lib/uniword/ooxml/namespaces.rb +62 -2
  66. data/lib/uniword/ooxml/relationships/package_relationships.rb +7 -0
  67. data/lib/uniword/ooxml/schema/element_serializer.rb +4 -1
  68. data/lib/uniword/presentationml/slide_id.rb +0 -1
  69. data/lib/uniword/presentationml/slide_master_id.rb +0 -1
  70. data/lib/uniword/properties/bold.rb +0 -2
  71. data/lib/uniword/properties/boolean_formatting.rb +0 -2
  72. data/lib/uniword/properties/borders.rb +1 -1
  73. data/lib/uniword/properties/italic.rb +0 -2
  74. data/lib/uniword/properties/numbering_properties.rb +1 -1
  75. data/lib/uniword/properties/style_reference.rb +13 -0
  76. data/lib/uniword/quality/rules/heading_hierarchy_rule.rb +1 -1
  77. data/lib/uniword/quality/rules/style_consistency_rule.rb +1 -1
  78. data/lib/uniword/resource/theme_processor.rb +1 -1
  79. data/lib/uniword/review/review_manager.rb +0 -2
  80. data/lib/uniword/template/helpers/loop_helper.rb +2 -2
  81. data/lib/uniword/template/variable_resolver.rb +18 -1
  82. data/lib/uniword/theme/theme_xml_parser.rb +1 -1
  83. data/lib/uniword/themes/theme_transformation.rb +1 -1
  84. data/lib/uniword/toc/toc_generator.rb +3 -14
  85. data/lib/uniword/transformation/mhtml_element_renderer.rb +94 -37
  86. data/lib/uniword/transformation/mhtml_metadata_builder.rb +1 -1
  87. data/lib/uniword/transformation/ooxml_to_html_converter.rb +3 -5
  88. data/lib/uniword/validation/checkers/footnote_reference_checker.rb +4 -1
  89. data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
  90. data/lib/uniword/validation/rules/document_context.rb +1 -1
  91. data/lib/uniword/version.rb +1 -1
  92. data/lib/uniword/visitor/text_extractor.rb +2 -1
  93. data/lib/uniword/warnings/warning_collector.rb +6 -1
  94. data/lib/uniword/watermark/manager.rb +2 -2
  95. data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
  96. data/lib/uniword/wordprocessingml/body.rb +51 -34
  97. data/lib/uniword/wordprocessingml/body_div.rb +17 -0
  98. data/lib/uniword/wordprocessingml/div_border.rb +23 -0
  99. data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
  100. data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
  101. data/lib/uniword/wordprocessingml/document_root.rb +4 -3
  102. data/lib/uniword/wordprocessingml/endnote.rb +1 -1
  103. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  104. data/lib/uniword/wordprocessingml/font.rb +1 -1
  105. data/lib/uniword/wordprocessingml/footer.rb +4 -0
  106. data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
  107. data/lib/uniword/wordprocessingml/footnote.rb +1 -1
  108. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  109. data/lib/uniword/wordprocessingml/header.rb +4 -0
  110. data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
  111. data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
  112. data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
  113. data/lib/uniword/wordprocessingml/level.rb +2 -0
  114. data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
  115. data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
  116. data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
  117. data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
  118. data/lib/uniword/wordprocessingml/num.rb +4 -0
  119. data/lib/uniword/wordprocessingml/numbering.rb +1 -22
  120. data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
  121. data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
  122. data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
  123. data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
  124. data/lib/uniword/wordprocessingml/run.rb +32 -36
  125. data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
  126. data/lib/uniword/wordprocessingml/run_properties.rb +28 -55
  127. data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
  128. data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
  129. data/lib/uniword/wordprocessingml/settings.rb +19 -17
  130. data/lib/uniword/wordprocessingml/style.rb +2 -2
  131. data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
  132. data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
  133. data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
  134. data/lib/uniword/wordprocessingml/table_properties.rb +10 -15
  135. data/lib/uniword/wordprocessingml/text.rb +8 -2
  136. data/lib/uniword/wordprocessingml/web_div.rb +41 -0
  137. data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
  138. data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
  139. data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
  140. data/lib/uniword/wordprocessingml/zoom.rb +2 -0
  141. data/lib/uniword/wordprocessingml.rb +23 -0
  142. data/lib/uniword.rb +27 -0
  143. metadata +46 -70
  144. data/lib/uniword/math/accent.rb +0 -25
  145. data/lib/uniword/math/accent_properties.rb +0 -25
  146. data/lib/uniword/math/argument_properties.rb +0 -22
  147. data/lib/uniword/math/bar.rb +0 -25
  148. data/lib/uniword/math/bar_properties.rb +0 -25
  149. data/lib/uniword/math/begin_char.rb +0 -22
  150. data/lib/uniword/math/border_box.rb +0 -25
  151. data/lib/uniword/math/border_box_properties.rb +0 -39
  152. data/lib/uniword/math/box.rb +0 -25
  153. data/lib/uniword/math/box_properties.rb +0 -33
  154. data/lib/uniword/math/char.rb +0 -22
  155. data/lib/uniword/math/control_properties.rb +0 -27
  156. data/lib/uniword/math/degree.rb +0 -26
  157. data/lib/uniword/math/delimiter.rb +0 -25
  158. data/lib/uniword/math/delimiter_properties.rb +0 -33
  159. data/lib/uniword/math/denominator.rb +0 -26
  160. data/lib/uniword/math/element.rb +0 -61
  161. data/lib/uniword/math/end_char.rb +0 -22
  162. data/lib/uniword/math/equation_array.rb +0 -25
  163. data/lib/uniword/math/equation_array_properties.rb +0 -33
  164. data/lib/uniword/math/fraction.rb +0 -27
  165. data/lib/uniword/math/fraction_properties.rb +0 -25
  166. data/lib/uniword/math/function.rb +0 -27
  167. data/lib/uniword/math/function_name.rb +0 -26
  168. data/lib/uniword/math/function_properties.rb +0 -23
  169. data/lib/uniword/math/group_char.rb +0 -25
  170. data/lib/uniword/math/group_char_properties.rb +0 -29
  171. data/lib/uniword/math/lim.rb +0 -26
  172. data/lib/uniword/math/lower_limit.rb +0 -27
  173. data/lib/uniword/math/lower_limit_properties.rb +0 -23
  174. data/lib/uniword/math/math_break.rb +0 -22
  175. data/lib/uniword/math/math_font.rb +0 -22
  176. data/lib/uniword/math/math_properties.rb +0 -53
  177. data/lib/uniword/math/math_run.rb +0 -42
  178. data/lib/uniword/math/math_run_properties.rb +0 -37
  179. data/lib/uniword/math/math_simple_int_val.rb +0 -24
  180. data/lib/uniword/math/math_simple_val.rb +0 -24
  181. data/lib/uniword/math/math_style.rb +0 -22
  182. data/lib/uniword/math/math_text.rb +0 -23
  183. data/lib/uniword/math/matrix.rb +0 -25
  184. data/lib/uniword/math/matrix_column.rb +0 -23
  185. data/lib/uniword/math/matrix_column_properties.rb +0 -24
  186. data/lib/uniword/math/matrix_columns.rb +0 -23
  187. data/lib/uniword/math/matrix_properties.rb +0 -37
  188. data/lib/uniword/math/matrix_row.rb +0 -23
  189. data/lib/uniword/math/nary.rb +0 -29
  190. data/lib/uniword/math/nary_properties.rb +0 -33
  191. data/lib/uniword/math/numerator.rb +0 -26
  192. data/lib/uniword/math/o_math.rb +0 -65
  193. data/lib/uniword/math/o_math_para.rb +0 -25
  194. data/lib/uniword/math/o_math_para_properties.rb +0 -22
  195. data/lib/uniword/math/phantom.rb +0 -25
  196. data/lib/uniword/math/phantom_properties.rb +0 -33
  197. data/lib/uniword/math/pre_sub_superscript.rb +0 -29
  198. data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
  199. data/lib/uniword/math/radical.rb +0 -27
  200. data/lib/uniword/math/radical_properties.rb +0 -25
  201. data/lib/uniword/math/separator_char.rb +0 -22
  202. data/lib/uniword/math/sub.rb +0 -26
  203. data/lib/uniword/math/sub_superscript.rb +0 -29
  204. data/lib/uniword/math/sub_superscript_properties.rb +0 -25
  205. data/lib/uniword/math/subscript.rb +0 -27
  206. data/lib/uniword/math/subscript_properties.rb +0 -23
  207. data/lib/uniword/math/sup.rb +0 -26
  208. data/lib/uniword/math/superscript.rb +0 -27
  209. data/lib/uniword/math/superscript_properties.rb +0 -23
  210. data/lib/uniword/math/upper_limit.rb +0 -27
  211. data/lib/uniword/math/upper_limit_properties.rb +0 -23
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Uniword
6
+ module Docx
7
+ # Single owner of all ID assignment in a DOCX package.
8
+ #
9
+ # Every builder, the adapter, and the reconciler call into this class
10
+ # for rId, footnote, endnote, bookmark, comment, and paragraph ID
11
+ # allocation. No other code generates IDs.
12
+ #
13
+ # Two usage modes:
14
+ # Creating from scratch — allocator starts empty, all IDs are fresh.
15
+ # Editing a template — call seed_from_* methods BEFORE any builder
16
+ # runs, so new IDs don't collide with existing ones.
17
+ #
18
+ # This is the "populate-first" principle: when loading a template DOCX,
19
+ # parse and seed ALL existing IDs from the template before modification.
20
+ 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"
32
+
33
+ def initialize
34
+ @rid_counter = 0
35
+ @rid_entries = {} # [target, type] -> { id, type, target, target_mode }
36
+ @footnote_counter = 1
37
+ @endnote_counter = 1
38
+ @bookmark_counter = 0
39
+ @comment_counter = 0
40
+ @para_counter = 0
41
+ @rsid_counter = 0
42
+ end
43
+
44
+ # Allocate a relationship ID for a target+type pair.
45
+ # 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]
48
+ @rid_entries[key] ||= begin
49
+ @rid_counter += 1
50
+ { id: "rId#{@rid_counter}", type: type.to_s,
51
+ target: target, target_mode: target_mode }
52
+ end
53
+ @rid_entries[key][:id]
54
+ end
55
+
56
+ def alloc_footnote_id
57
+ id = @footnote_counter
58
+ @footnote_counter += 1
59
+ id
60
+ end
61
+
62
+ def alloc_endnote_id
63
+ id = @endnote_counter
64
+ @endnote_counter += 1
65
+ id
66
+ end
67
+
68
+ def alloc_bookmark_id
69
+ @bookmark_counter += 1
70
+ @bookmark_counter.to_s
71
+ end
72
+
73
+ def alloc_comment_id
74
+ @comment_counter += 1
75
+ @comment_counter.to_s
76
+ end
77
+
78
+ def alloc_para_id
79
+ @para_counter += 1
80
+ Digest::SHA256.hexdigest("para:#{@para_counter}").upcase[0, 8]
81
+ end
82
+
83
+ def alloc_rsid
84
+ @rsid_counter += 1
85
+ Digest::SHA256.hexdigest("rsid:#{@rsid_counter}").upcase[0, 8]
86
+ end
87
+
88
+ # Seed from a relationships collection — preserves existing rIds.
89
+ def seed_from_rels(relationships)
90
+ return unless relationships
91
+
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
103
+ end
104
+
105
+ # Seed footnote/endnote counters from existing note entries.
106
+ def seed_from_notes(footnote_entries, endnote_entries)
107
+ footnote_entries&.each do |e|
108
+ id = e.id.to_i
109
+ @footnote_counter = [@footnote_counter, id + 1].max if id > 0
110
+ end
111
+ endnote_entries&.each do |e|
112
+ id = e.id.to_i
113
+ @endnote_counter = [@endnote_counter, id + 1].max if id > 0
114
+ end
115
+ end
116
+
117
+ # 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 }
120
+ end
121
+
122
+ # Check if a relationship has been registered for a target+type.
123
+ def rid_for(target:, type:)
124
+ key = [target, type.to_s]
125
+ @rid_entries[key]&.fetch(:id, nil)
126
+ end
127
+
128
+ # Build an allocator seeded from every source on a package in one call.
129
+ # Used by both Package#populate_allocator and DocumentBuilder.from_template
130
+ # so they cannot drift apart.
131
+ def self.populate_from_package(package)
132
+ alloc = new
133
+ alloc.seed_from_rels(package.document_rels&.relationships)
134
+ alloc.seed_from_rels(package.package_rels&.relationships)
135
+ alloc.seed_from_notes(
136
+ package.footnotes&.footnote_entries,
137
+ package.endnotes&.endnote_entries,
138
+ )
139
+ alloc
140
+ end
141
+ end
142
+ end
143
+ end
@@ -2,9 +2,6 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "lutaml/model"
5
- require_relative "reconciler"
6
- require_relative "package_defaults"
7
- require_relative "package_serialization"
8
5
 
9
6
  module Uniword
10
7
  module Docx
@@ -89,6 +86,18 @@ module Uniword
89
86
  attr_accessor :chart_parts, :bibliography_sources, :profile
90
87
  attr_accessor :settings_rels, :embeddings
91
88
 
89
+ # Central ID allocator — owns all rId, footnote, bookmark, etc. assignment.
90
+ # Seeded from template rels on load; used by builders during construction.
91
+ attr_accessor :allocator
92
+
93
+ # Raw XML from template ZIP for unmodified parts.
94
+ # When present, these are used verbatim instead of re-serializing
95
+ # through lutaml-model (which drops unmapped elements).
96
+ attr_accessor :raw_xml_parts
97
+
98
+ # Paths that have been modified and should not use raw XML passthrough.
99
+ attr_accessor :modified_part_paths
100
+
92
101
  # Load DOCX package from file
93
102
  #
94
103
  # @param path [String] Path to .docx file
@@ -96,7 +105,9 @@ module Uniword
96
105
  def self.from_file(path)
97
106
  extractor = Infrastructure::ZipExtractor.new
98
107
  zip_content = extractor.extract(path)
99
- from_zip_content(zip_content, path)
108
+ package = from_zip_content(zip_content, path)
109
+ package.populate_allocator
110
+ package
100
111
  end
101
112
 
102
113
  # Create package from extracted ZIP content
@@ -364,7 +375,14 @@ module Uniword
364
375
  else "image/#{ext}"
365
376
  end
366
377
 
367
- r_id = "rIdImg#{package.document.image_parts.size + 1}"
378
+ r_id = if package.allocator
379
+ package.allocator.alloc_rid(
380
+ target: "media/#{filename}",
381
+ type: IdAllocator::IMAGE_REL_TYPE,
382
+ )
383
+ else
384
+ "rId#{package.document.image_parts.size + 1}"
385
+ end
368
386
 
369
387
  binary_data = if zip_path
370
388
  read_binary_from_zip(zip_path, media_path)
@@ -406,6 +424,12 @@ module Uniword
406
424
  package.to_file(path)
407
425
  end
408
426
 
427
+ # Populate the allocator from all existing template data.
428
+ # Must be called BEFORE any builder runs (populate-first principle).
429
+ def populate_allocator
430
+ @allocator = IdAllocator.populate_from_package(self)
431
+ end
432
+
409
433
  # Extract media files from word/theme/media/ directory
410
434
  def self.extract_theme_media(zip_content)
411
435
  media = {}
@@ -443,14 +467,26 @@ module Uniword
443
467
  self.font_table ||= Uniword::Wordprocessingml::FontTable.new
444
468
  self.web_settings ||= Uniword::Wordprocessingml::WebSettings.new
445
469
 
446
- Reconciler.new(self, profile: profile || Profile.defaults).reconcile
470
+ Reconciler.new(self,
471
+ profile: profile || Profile.defaults,
472
+ allocator: allocator).reconcile
447
473
 
448
474
  inject_part_relationships(content, content_types, package_rels, document_rels)
449
475
  serialize_package_parts(content, content_types, package_rels, document_rels)
450
476
 
477
+ # OOXML requires [Content_Types].xml as the first ZIP entry.
478
+ reorder_content_hash(content)
451
479
  content
452
480
  end
453
481
 
482
+ # Ensure [Content_Types].xml is first, _rels/.rels is second.
483
+ def reorder_content_hash(content)
484
+ priority = {}
485
+ priority["[Content_Types].xml"] = content.delete("[Content_Types].xml") if content.key?("[Content_Types].xml")
486
+ priority["_rels/.rels"] = content.delete("_rels/.rels") if content.key?("_rels/.rels")
487
+ content.replace(priority.merge(content))
488
+ end
489
+
454
490
  # Delegate common DocumentRoot methods for API compatibility
455
491
 
456
492
  def paragraphs
@@ -9,29 +9,8 @@ module Uniword
9
9
  #
10
10
  # @api private
11
11
  module PackageDefaults
12
- # Mapping from DocumentRoot attributes to Package attributes.
13
- # Only entries where names differ need explicit mapping.
14
- DOCUMENT_TO_PACKAGE_MAPPINGS = {
15
- styles_configuration: :styles,
16
- settings: :settings,
17
- font_table: :font_table,
18
- web_settings: :web_settings,
19
- theme: :theme,
20
- core_properties: :core_properties,
21
- app_properties: :app_properties,
22
- custom_properties: :custom_properties,
23
- document_rels: :document_rels,
24
- theme_rels: :theme_rels,
25
- package_rels: :package_rels,
26
- content_types: :content_types,
27
- footnotes: :footnotes,
28
- endnotes: :endnotes,
29
- }.freeze
30
-
31
12
  def self.included(base)
32
13
  base.extend(ClassMethods)
33
- base.const_set(:DOCUMENT_TO_PACKAGE_MAPPINGS,
34
- DOCUMENT_TO_PACKAGE_MAPPINGS)
35
14
  end
36
15
 
37
16
  # Class methods for package construction
@@ -40,10 +19,21 @@ module Uniword
40
19
  def copy_document_parts_to_package(document, package)
41
20
  return unless document.is_a?(Uniword::Wordprocessingml::DocumentRoot)
42
21
 
43
- DOCUMENT_TO_PACKAGE_MAPPINGS.each do |doc_attr, pkg_attr|
44
- value = document.public_send(doc_attr)
45
- package.public_send(:"#{pkg_attr}=", value) if value
46
- end
22
+ package.styles = document.styles_configuration if document.styles_configuration
23
+ package.settings = document.settings if document.settings
24
+ package.font_table = document.font_table if document.font_table
25
+ package.web_settings = document.web_settings if document.web_settings
26
+ package.theme = document.theme if document.theme
27
+ package.core_properties = document.core_properties if document.core_properties
28
+ package.app_properties = document.app_properties if document.app_properties
29
+ package.custom_properties = document.custom_properties if document.custom_properties
30
+ package.document_rels = document.document_rels if document.document_rels
31
+ package.theme_rels = document.theme_rels if document.theme_rels
32
+ package.package_rels = document.package_rels if document.package_rels
33
+ package.content_types = document.content_types if document.content_types
34
+ package.footnotes = document.footnotes if document.footnotes
35
+ package.endnotes = document.endnotes if document.endnotes
36
+ package.allocator = document.allocator if document.allocator
47
37
 
48
38
  package.numbering = document.numbering_configuration if document.numbering_configuration_loaded?
49
39
 
@@ -12,6 +12,30 @@ module Uniword
12
12
  #
13
13
  # @api private
14
14
  module PackageSerialization
15
+ DOCX_XML_OPTIONS = {
16
+ encoding: "UTF-8",
17
+ indent: 0,
18
+ line_ending: "\r\n",
19
+ }.freeze
20
+
21
+ DOCX_PART_OPTIONS = {
22
+ **DOCX_XML_OPTIONS,
23
+ prefix: true,
24
+ standalone: true,
25
+ }.freeze
26
+
27
+ DOCX_INFRA_OPTIONS = {
28
+ **DOCX_XML_OPTIONS,
29
+ declaration: true,
30
+ standalone: true,
31
+ }.freeze
32
+
33
+ DOCX_PROPS_OPTIONS = {
34
+ **DOCX_XML_OPTIONS,
35
+ prefix: false,
36
+ standalone: true,
37
+ }.freeze
38
+
15
39
  # Inject content types and relationships for all package parts
16
40
  def inject_part_relationships(content, content_types, package_rels,
17
41
  document_rels)
@@ -45,15 +69,11 @@ document_rels)
45
69
  end
46
70
  if app_properties
47
71
  content["docProps/app.xml"] =
48
- app_properties.to_xml(encoding: "UTF-8",
49
- prefix: false,
50
- standalone: true)
72
+ add_standalone(app_properties.to_xml(DOCX_PROPS_OPTIONS.dup))
51
73
  end
52
74
  if custom_properties
53
75
  content["docProps/custom.xml"] =
54
- custom_properties.to_xml(encoding: "UTF-8",
55
- prefix: false,
56
- standalone: true)
76
+ add_standalone(custom_properties.to_xml(DOCX_PROPS_OPTIONS.dup))
57
77
  end
58
78
 
59
79
  # Custom XML data items
@@ -141,18 +161,57 @@ document_rels)
141
161
  serialize_embeddings(content)
142
162
  end
143
163
 
144
- # Serialize an OOXML document part with standard encoding
164
+ # Serialize an OOXML document part with standard encoding, single-line output.
165
+ # All DOCX parts require standalone="yes" in the XML declaration for Word compatibility.
145
166
  def serialize_part(model)
146
- model.to_xml(encoding: "UTF-8", prefix: true, standalone: true)
167
+ add_standalone(model.to_xml(DOCX_PART_OPTIONS.dup))
147
168
  end
148
169
 
149
- # Serialize package infrastructure (rels, content types) with declaration
170
+ # Serialize package infrastructure (rels, content types) with declaration, single-line output
150
171
  def serialize_infrastructure(model)
151
- model.to_xml(encoding: "UTF-8", declaration: true, standalone: true)
172
+ add_standalone(model.to_xml(DOCX_INFRA_OPTIONS.dup))
173
+ end
174
+
175
+ # Ensure the XML declaration includes standalone="yes".
176
+ # lutaml-model omits this by default; Word's strict OPC validation requires it.
177
+ def add_standalone(xml)
178
+ xml.sub(
179
+ %r{<\?xml version="1.0" encoding="UTF-8"\?>},
180
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
181
+ )
152
182
  end
153
183
 
154
184
  private
155
185
 
186
+ # Generate the next numeric relationship ID for a Relationships object.
187
+ def next_rid(relationships)
188
+ Ooxml::Relationships::PackageRelationships.next_available_rid(
189
+ relationships,
190
+ )
191
+ end
192
+
193
+ # Shared pattern: ensure a part has both a content type override and
194
+ # a document relationship. Idempotent — skips if already present.
195
+ # When allocator is present, only adds content type (rels handled by allocator).
196
+ def ensure_content_type(content_types, rels, part_name:,
197
+ content_type:, rel_type:, target:)
198
+ unless content_types.overrides.any? { |o| o.part_name == part_name }
199
+ content_types.overrides << Uniword::ContentTypes::Override.new(
200
+ part_name: part_name, content_type: content_type,
201
+ )
202
+ end
203
+
204
+ return if allocator
205
+ return if rels.relationships.any? { |r| r.target == target }
206
+
207
+ rels.relationships << Ooxml::Relationships::Relationship.new(
208
+ id: next_rid(rels), type: rel_type, target: target,
209
+ )
210
+ end
211
+
212
+ # Legacy alias — calls ensure_content_type (same behavior)
213
+ alias ensure_part_registered ensure_content_type
214
+
156
215
  def inject_image_parts(content, content_types, document_rels)
157
216
  return unless document&.image_parts && !document.image_parts.empty?
158
217
 
@@ -165,8 +224,15 @@ document_rels)
165
224
  )
166
225
  end
167
226
 
168
- document.image_parts.each do |r_id, image_data|
227
+ document.image_parts.each_value do |image_data|
169
228
  content["word/#{image_data[:target]}"] = image_data[:data]
229
+ end
230
+
231
+ return if allocator
232
+
233
+ document.image_parts.each do |r_id, image_data|
234
+ next if document_rels.relationships.any? { |r| r.target == image_data[:target] }
235
+
170
236
  document_rels.relationships << Ooxml::Relationships::Relationship.new(
171
237
  id: r_id,
172
238
  type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
@@ -200,24 +266,11 @@ document_rels)
200
266
  def inject_bibliography(content_types, document_rels)
201
267
  return unless document&.bibliography_sources
202
268
 
203
- unless content_types.overrides.any? do |o|
204
- o.part_name == "/word/sources.xml"
205
- end
206
- content_types.overrides << Uniword::ContentTypes::Override.new(
207
- part_name: "/word/sources.xml",
208
- content_type: "application/vnd.openxmlformats-officedocument.bibliography+xml",
209
- )
210
- end
211
-
212
- return if document_rels.relationships.any? do |r|
213
- r.target == "sources.xml"
214
- end
215
-
216
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
217
- id: "rIdSrc#{SecureRandom.hex(4)}",
218
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/bibliography",
219
- target: "sources.xml",
220
- )
269
+ ensure_content_type(content_types, document_rels,
270
+ part_name: "/word/sources.xml",
271
+ content_type: "application/vnd.openxmlformats-officedocument.bibliography+xml",
272
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/bibliography",
273
+ target: "sources.xml")
221
274
  end
222
275
 
223
276
  def inject_custom_properties(content_types, package_rels)
@@ -232,15 +285,15 @@ document_rels)
232
285
  )
233
286
  end
234
287
 
235
- unless package_rels.relationships.any? do |r|
288
+ return if package_rels.relationships.any? do |r|
236
289
  r.type.to_s.include?("officeDocument/2006/relationships/custom-properties")
237
290
  end
238
- package_rels.relationships << Ooxml::Relationships::Relationship.new(
239
- id: "rIdCustProps",
240
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
241
- target: "docProps/custom.xml",
242
- )
243
- end
291
+
292
+ package_rels.relationships << Ooxml::Relationships::Relationship.new(
293
+ id: next_rid(package_rels),
294
+ type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
295
+ target: "docProps/custom.xml",
296
+ )
244
297
  end
245
298
 
246
299
  def inject_custom_xml(content_types)
@@ -265,17 +318,23 @@ document_rels)
265
318
  counter = 0
266
319
  document.headers.each_key do |type|
267
320
  counter += 1
268
- r_id = "rIdHeader#{counter}"
321
+ target = "header#{counter}.xml"
269
322
 
270
- content_types.overrides << Uniword::ContentTypes::Override.new(
271
- part_name: "/word/header#{counter}.xml",
272
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
273
- )
323
+ unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
324
+ content_types.overrides << Uniword::ContentTypes::Override.new(
325
+ part_name: "/word/#{target}",
326
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
327
+ )
328
+ end
274
329
 
330
+ next if allocator
331
+ next if document_rels.relationships.any? { |r| r.target == target }
332
+
333
+ r_id = next_rid(document_rels)
275
334
  document_rels.relationships << Ooxml::Relationships::Relationship.new(
276
335
  id: r_id,
277
336
  type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
278
- target: "header#{counter}.xml",
337
+ target: target,
279
338
  )
280
339
 
281
340
  wire_header_reference(type, r_id)
@@ -288,17 +347,23 @@ document_rels)
288
347
  counter = 0
289
348
  document.footers.each_key do |type|
290
349
  counter += 1
291
- r_id = "rIdFooter#{counter}"
350
+ target = "footer#{counter}.xml"
292
351
 
293
- content_types.overrides << Uniword::ContentTypes::Override.new(
294
- part_name: "/word/footer#{counter}.xml",
295
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
296
- )
352
+ unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
353
+ content_types.overrides << Uniword::ContentTypes::Override.new(
354
+ part_name: "/word/#{target}",
355
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
356
+ )
357
+ end
297
358
 
359
+ next if allocator
360
+ next if document_rels.relationships.any? { |r| r.target == target }
361
+
362
+ r_id = next_rid(document_rels)
298
363
  document_rels.relationships << Ooxml::Relationships::Relationship.new(
299
364
  id: r_id,
300
365
  type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
301
- target: "footer#{counter}.xml",
366
+ target: target,
302
367
  )
303
368
 
304
369
  wire_footer_reference(type, r_id)
@@ -317,104 +382,53 @@ document_rels)
317
382
  )
318
383
  end
319
384
 
320
- unless document_rels.relationships.any? { |r| r.id == part[:r_id] }
321
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
322
- id: part[:r_id],
323
- type: part[:rel_type],
324
- target: part[:target],
325
- )
326
- end
385
+ next if allocator
386
+ next if document_rels.relationships.any? { |r| r.id == part[:r_id] }
387
+
388
+ document_rels.relationships << Ooxml::Relationships::Relationship.new(
389
+ id: part[:r_id],
390
+ type: part[:rel_type],
391
+ target: part[:target],
392
+ )
327
393
  end
328
394
  end
329
395
 
330
396
  def inject_notes(content_types, document_rels)
331
397
  if footnotes
332
- unless content_types.overrides.any? do |o|
333
- o.part_name == "/word/footnotes.xml"
334
- end
335
- content_types.overrides << Uniword::ContentTypes::Override.new(
336
- part_name: "/word/footnotes.xml",
337
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
338
- )
339
- end
340
-
341
- unless document_rels.relationships.any? do |r|
342
- r.target == "footnotes.xml"
343
- end
344
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
345
- id: "rIdFootnotes",
346
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",
347
- target: "footnotes.xml",
348
- )
349
- end
398
+ ensure_content_type(content_types, document_rels,
399
+ part_name: "/word/footnotes.xml",
400
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
401
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",
402
+ target: "footnotes.xml")
350
403
  end
351
404
 
352
405
  return unless endnotes
353
406
 
354
- unless content_types.overrides.any? do |o|
355
- o.part_name == "/word/endnotes.xml"
356
- end
357
- content_types.overrides << Uniword::ContentTypes::Override.new(
358
- part_name: "/word/endnotes.xml",
359
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
360
- )
361
- end
362
-
363
- return if document_rels.relationships.any? do |r|
364
- r.target == "endnotes.xml"
365
- end
366
-
367
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
368
- id: "rIdEndnotes",
369
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
370
- target: "endnotes.xml",
371
- )
407
+ ensure_content_type(content_types, document_rels,
408
+ part_name: "/word/endnotes.xml",
409
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
410
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
411
+ target: "endnotes.xml")
372
412
  end
373
413
 
374
414
  def inject_theme(content_types, document_rels)
375
415
  return unless theme
376
416
 
377
- unless content_types.overrides.any? do |o|
378
- o.part_name == "/word/theme/theme1.xml"
379
- end
380
- content_types.overrides << Uniword::ContentTypes::Override.new(
381
- part_name: "/word/theme/theme1.xml",
382
- content_type: "application/vnd.openxmlformats-officedocument.theme+xml",
383
- )
384
- end
385
-
386
- return if document_rels.relationships.any? do |r|
387
- r.target == "theme/theme1.xml"
388
- end
389
-
390
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
391
- id: "rIdTheme",
392
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
393
- target: "theme/theme1.xml",
394
- )
417
+ ensure_content_type(content_types, document_rels,
418
+ part_name: "/word/theme/theme1.xml",
419
+ content_type: "application/vnd.openxmlformats-officedocument.theme+xml",
420
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
421
+ target: "theme/theme1.xml")
395
422
  end
396
423
 
397
424
  def inject_numbering(content_types, document_rels)
398
425
  return unless numbering
399
426
 
400
- unless content_types.overrides.any? do |o|
401
- o.part_name == "/word/numbering.xml"
402
- end
403
- content_types.overrides << Uniword::ContentTypes::Override.new(
404
- part_name: "/word/numbering.xml",
405
- content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
406
- )
407
- end
408
-
409
- return if document_rels.relationships.any? do |r|
410
- r.target == "numbering.xml"
411
- end
412
-
413
- document_rels.relationships << Ooxml::Relationships::Relationship.new(
414
- id: "rIdNumbering",
415
- type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
416
- target: "numbering.xml",
417
- )
427
+ ensure_content_type(content_types, document_rels,
428
+ part_name: "/word/numbering.xml",
429
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
430
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
431
+ target: "numbering.xml")
418
432
  end
419
433
 
420
434
  def inject_embeddings(content_types, document_rels)