uniword 1.0.11 → 1.2.4

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 (200) 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 +33 -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/header_footer_builder.rb +10 -10
  13. data/lib/uniword/builder/image_builder.rb +24 -16
  14. data/lib/uniword/builder/list_builder.rb +2 -1
  15. data/lib/uniword/builder/paragraph_builder.rb +95 -20
  16. data/lib/uniword/builder/run_utils.rb +97 -0
  17. data/lib/uniword/builder/section_builder.rb +40 -67
  18. data/lib/uniword/builder/table_builder.rb +32 -13
  19. data/lib/uniword/builder/table_cell_builder.rb +19 -0
  20. data/lib/uniword/builder.rb +27 -19
  21. data/lib/uniword/cli/diff_cli.rb +0 -1
  22. data/lib/uniword/cli/generate_cli.rb +0 -1
  23. data/lib/uniword/cli/headers_cli.rb +0 -1
  24. data/lib/uniword/cli/images_cli.rb +0 -1
  25. data/lib/uniword/cli/main.rb +6 -7
  26. data/lib/uniword/cli/protect_cli.rb +0 -1
  27. data/lib/uniword/cli/resources_cli.rb +0 -1
  28. data/lib/uniword/cli/review_cli.rb +0 -1
  29. data/lib/uniword/cli/spellcheck_cli.rb +0 -1
  30. data/lib/uniword/cli/styleset_cli.rb +0 -1
  31. data/lib/uniword/cli/template_cli.rb +0 -1
  32. data/lib/uniword/cli/theme_cli.rb +0 -1
  33. data/lib/uniword/cli/toc_cli.rb +0 -1
  34. data/lib/uniword/cli/watermark_cli.rb +0 -1
  35. data/lib/uniword/cli.rb +6 -17
  36. data/lib/uniword/diff/document_differ.rb +23 -5
  37. data/lib/uniword/diff/package_differ.rb +0 -1
  38. data/lib/uniword/diff.rb +4 -0
  39. data/lib/uniword/document_factory.rb +16 -25
  40. data/lib/uniword/docx/document_statistics.rb +42 -54
  41. data/lib/uniword/docx/id_allocator.rb +143 -0
  42. data/lib/uniword/docx/package.rb +42 -6
  43. data/lib/uniword/docx/package_defaults.rb +15 -25
  44. data/lib/uniword/docx/package_serialization.rb +131 -127
  45. data/lib/uniword/docx/reconciler/body.rb +197 -0
  46. data/lib/uniword/docx/reconciler/fix_codes.rb +74 -0
  47. data/lib/uniword/docx/reconciler/helpers.rb +337 -0
  48. data/lib/uniword/docx/reconciler/notes.rb +301 -0
  49. data/lib/uniword/docx/reconciler/package_structure.rb +322 -0
  50. data/lib/uniword/docx/reconciler/parts.rb +484 -0
  51. data/lib/uniword/docx/reconciler/referential_integrity.rb +449 -0
  52. data/lib/uniword/docx/reconciler/tables.rb +162 -0
  53. data/lib/uniword/docx/reconciler/theme.rb +141 -0
  54. data/lib/uniword/docx/reconciler.rb +44 -1097
  55. data/lib/uniword/docx.rb +3 -0
  56. data/lib/uniword/drawingml/color_scheme.rb +38 -5
  57. data/lib/uniword/hyperlink.rb +27 -26
  58. data/lib/uniword/infrastructure/zip_packager.rb +41 -38
  59. data/lib/uniword/lazy_loader.rb +36 -62
  60. data/lib/uniword/math.rb +3 -86
  61. data/lib/uniword/mhtml/document.rb +1 -3
  62. data/lib/uniword/model_attribute_access.rb +27 -0
  63. data/lib/uniword/ooxml/namespaces.rb +62 -2
  64. data/lib/uniword/ooxml/relationships/package_relationships.rb +7 -0
  65. data/lib/uniword/presentationml/slide_id.rb +0 -1
  66. data/lib/uniword/presentationml/slide_master_id.rb +0 -1
  67. data/lib/uniword/properties/bold.rb +0 -2
  68. data/lib/uniword/properties/boolean_formatting.rb +0 -2
  69. data/lib/uniword/properties/borders.rb +1 -1
  70. data/lib/uniword/properties/italic.rb +0 -2
  71. data/lib/uniword/properties/numbering_properties.rb +1 -1
  72. data/lib/uniword/properties/style_reference.rb +13 -0
  73. data/lib/uniword/quality/rules/heading_hierarchy_rule.rb +1 -1
  74. data/lib/uniword/quality/rules/style_consistency_rule.rb +1 -1
  75. data/lib/uniword/review/review_manager.rb +0 -2
  76. data/lib/uniword/template/helpers/loop_helper.rb +2 -2
  77. data/lib/uniword/template/variable_resolver.rb +14 -1
  78. data/lib/uniword/theme/theme_xml_parser.rb +1 -1
  79. data/lib/uniword/toc/toc_generator.rb +3 -14
  80. data/lib/uniword/transformation/mhtml_element_renderer.rb +94 -37
  81. data/lib/uniword/transformation/mhtml_metadata_builder.rb +1 -1
  82. data/lib/uniword/transformation/ooxml_to_html_converter.rb +3 -5
  83. data/lib/uniword/validation/checkers/internal_link_checker.rb +2 -3
  84. data/lib/uniword/version.rb +1 -1
  85. data/lib/uniword/visitor/text_extractor.rb +2 -1
  86. data/lib/uniword/watermark/manager.rb +2 -2
  87. data/lib/uniword/wordprocessingml/allow_png.rb +14 -0
  88. data/lib/uniword/wordprocessingml/body.rb +51 -34
  89. data/lib/uniword/wordprocessingml/body_div.rb +17 -0
  90. data/lib/uniword/wordprocessingml/div_border.rb +23 -0
  91. data/lib/uniword/wordprocessingml/div_borders.rb +24 -0
  92. data/lib/uniword/wordprocessingml/divs_child.rb +17 -0
  93. data/lib/uniword/wordprocessingml/document_root.rb +4 -3
  94. data/lib/uniword/wordprocessingml/endnote.rb +1 -1
  95. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  96. data/lib/uniword/wordprocessingml/footer.rb +4 -0
  97. data/lib/uniword/wordprocessingml/footer_reference.rb +0 -1
  98. data/lib/uniword/wordprocessingml/footnote.rb +1 -1
  99. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  100. data/lib/uniword/wordprocessingml/header.rb +4 -0
  101. data/lib/uniword/wordprocessingml/header_reference.rb +0 -1
  102. data/lib/uniword/wordprocessingml/hyperlink.rb +2 -2
  103. data/lib/uniword/wordprocessingml/instr_text.rb +20 -5
  104. data/lib/uniword/wordprocessingml/level.rb +2 -0
  105. data/lib/uniword/wordprocessingml/mar_bottom.rb +17 -0
  106. data/lib/uniword/wordprocessingml/mar_left.rb +17 -0
  107. data/lib/uniword/wordprocessingml/mar_right.rb +17 -0
  108. data/lib/uniword/wordprocessingml/mar_top.rb +17 -0
  109. data/lib/uniword/wordprocessingml/num.rb +4 -0
  110. data/lib/uniword/wordprocessingml/numbering.rb +1 -22
  111. data/lib/uniword/wordprocessingml/optimize_for_browser.rb +14 -0
  112. data/lib/uniword/wordprocessingml/page_defaults.rb +39 -0
  113. data/lib/uniword/wordprocessingml/paragraph.rb +16 -13
  114. data/lib/uniword/wordprocessingml/paragraph_properties.rb +12 -8
  115. data/lib/uniword/wordprocessingml/run.rb +32 -36
  116. data/lib/uniword/wordprocessingml/run_properties/merging.rb +45 -0
  117. data/lib/uniword/wordprocessingml/run_properties.rb +8 -5
  118. data/lib/uniword/wordprocessingml/section_properties.rb +3 -3
  119. data/lib/uniword/wordprocessingml/separator_char.rb +21 -0
  120. data/lib/uniword/wordprocessingml/settings.rb +19 -17
  121. data/lib/uniword/wordprocessingml/styles/style_definition.rb +1 -1
  122. data/lib/uniword/wordprocessingml/styles/style_library.rb +16 -0
  123. data/lib/uniword/wordprocessingml/table_defaults.rb +44 -0
  124. data/lib/uniword/wordprocessingml/text.rb +8 -2
  125. data/lib/uniword/wordprocessingml/web_div.rb +41 -0
  126. data/lib/uniword/wordprocessingml/web_divs.rb +18 -0
  127. data/lib/uniword/wordprocessingml/web_encoding.rb +17 -0
  128. data/lib/uniword/wordprocessingml/web_settings.rb +5 -21
  129. data/lib/uniword/wordprocessingml/zoom.rb +2 -0
  130. data/lib/uniword/wordprocessingml.rb +23 -0
  131. data/lib/uniword.rb +27 -0
  132. metadata +46 -70
  133. data/lib/uniword/math/accent.rb +0 -25
  134. data/lib/uniword/math/accent_properties.rb +0 -25
  135. data/lib/uniword/math/argument_properties.rb +0 -22
  136. data/lib/uniword/math/bar.rb +0 -25
  137. data/lib/uniword/math/bar_properties.rb +0 -25
  138. data/lib/uniword/math/begin_char.rb +0 -22
  139. data/lib/uniword/math/border_box.rb +0 -25
  140. data/lib/uniword/math/border_box_properties.rb +0 -39
  141. data/lib/uniword/math/box.rb +0 -25
  142. data/lib/uniword/math/box_properties.rb +0 -33
  143. data/lib/uniword/math/char.rb +0 -22
  144. data/lib/uniword/math/control_properties.rb +0 -27
  145. data/lib/uniword/math/degree.rb +0 -26
  146. data/lib/uniword/math/delimiter.rb +0 -25
  147. data/lib/uniword/math/delimiter_properties.rb +0 -33
  148. data/lib/uniword/math/denominator.rb +0 -26
  149. data/lib/uniword/math/element.rb +0 -61
  150. data/lib/uniword/math/end_char.rb +0 -22
  151. data/lib/uniword/math/equation_array.rb +0 -25
  152. data/lib/uniword/math/equation_array_properties.rb +0 -33
  153. data/lib/uniword/math/fraction.rb +0 -27
  154. data/lib/uniword/math/fraction_properties.rb +0 -25
  155. data/lib/uniword/math/function.rb +0 -27
  156. data/lib/uniword/math/function_name.rb +0 -26
  157. data/lib/uniword/math/function_properties.rb +0 -23
  158. data/lib/uniword/math/group_char.rb +0 -25
  159. data/lib/uniword/math/group_char_properties.rb +0 -29
  160. data/lib/uniword/math/lim.rb +0 -26
  161. data/lib/uniword/math/lower_limit.rb +0 -27
  162. data/lib/uniword/math/lower_limit_properties.rb +0 -23
  163. data/lib/uniword/math/math_break.rb +0 -22
  164. data/lib/uniword/math/math_font.rb +0 -22
  165. data/lib/uniword/math/math_properties.rb +0 -53
  166. data/lib/uniword/math/math_run.rb +0 -42
  167. data/lib/uniword/math/math_run_properties.rb +0 -37
  168. data/lib/uniword/math/math_simple_int_val.rb +0 -24
  169. data/lib/uniword/math/math_simple_val.rb +0 -24
  170. data/lib/uniword/math/math_style.rb +0 -22
  171. data/lib/uniword/math/math_text.rb +0 -23
  172. data/lib/uniword/math/matrix.rb +0 -25
  173. data/lib/uniword/math/matrix_column.rb +0 -23
  174. data/lib/uniword/math/matrix_column_properties.rb +0 -24
  175. data/lib/uniword/math/matrix_columns.rb +0 -23
  176. data/lib/uniword/math/matrix_properties.rb +0 -37
  177. data/lib/uniword/math/matrix_row.rb +0 -23
  178. data/lib/uniword/math/nary.rb +0 -29
  179. data/lib/uniword/math/nary_properties.rb +0 -33
  180. data/lib/uniword/math/numerator.rb +0 -26
  181. data/lib/uniword/math/o_math.rb +0 -65
  182. data/lib/uniword/math/o_math_para.rb +0 -25
  183. data/lib/uniword/math/o_math_para_properties.rb +0 -22
  184. data/lib/uniword/math/phantom.rb +0 -25
  185. data/lib/uniword/math/phantom_properties.rb +0 -33
  186. data/lib/uniword/math/pre_sub_superscript.rb +0 -29
  187. data/lib/uniword/math/pre_sub_superscript_properties.rb +0 -23
  188. data/lib/uniword/math/radical.rb +0 -27
  189. data/lib/uniword/math/radical_properties.rb +0 -25
  190. data/lib/uniword/math/separator_char.rb +0 -22
  191. data/lib/uniword/math/sub.rb +0 -26
  192. data/lib/uniword/math/sub_superscript.rb +0 -29
  193. data/lib/uniword/math/sub_superscript_properties.rb +0 -25
  194. data/lib/uniword/math/subscript.rb +0 -27
  195. data/lib/uniword/math/subscript_properties.rb +0 -23
  196. data/lib/uniword/math/sup.rb +0 -26
  197. data/lib/uniword/math/superscript.rb +0 -27
  198. data/lib/uniword/math/superscript_properties.rb +0 -23
  199. data/lib/uniword/math/upper_limit.rb +0 -27
  200. 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, 12]
81
+ end
82
+
83
+ def alloc_rsid
84
+ @rsid_counter += 1
85
+ Digest::SHA256.hexdigest("rsid:#{@rsid_counter}").upcase[0, 12]
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
+ 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
+ custom_properties.to_xml(DOCX_PROPS_OPTIONS.dup)
57
77
  end
58
78
 
59
79
  # Custom XML data items
@@ -141,18 +161,47 @@ 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
145
165
  def serialize_part(model)
146
- model.to_xml(encoding: "UTF-8", prefix: true, standalone: true)
166
+ model.to_xml(DOCX_PART_OPTIONS.dup)
147
167
  end
148
168
 
149
- # Serialize package infrastructure (rels, content types) with declaration
169
+ # Serialize package infrastructure (rels, content types) with declaration, single-line output
150
170
  def serialize_infrastructure(model)
151
- model.to_xml(encoding: "UTF-8", declaration: true, standalone: true)
171
+ model.to_xml(DOCX_INFRA_OPTIONS.dup)
152
172
  end
153
173
 
154
174
  private
155
175
 
176
+ # Generate the next numeric relationship ID for a Relationships object.
177
+ def next_rid(relationships)
178
+ Ooxml::Relationships::PackageRelationships.next_available_rid(
179
+ relationships,
180
+ )
181
+ end
182
+
183
+ # Shared pattern: ensure a part has both a content type override and
184
+ # a document relationship. Idempotent — skips if already present.
185
+ # When allocator is present, only adds content type (rels handled by allocator).
186
+ def ensure_content_type(content_types, rels, part_name:,
187
+ content_type:, rel_type:, target:)
188
+ unless content_types.overrides.any? { |o| o.part_name == part_name }
189
+ content_types.overrides << Uniword::ContentTypes::Override.new(
190
+ part_name: part_name, content_type: content_type,
191
+ )
192
+ end
193
+
194
+ return if allocator
195
+ return if rels.relationships.any? { |r| r.target == target }
196
+
197
+ rels.relationships << Ooxml::Relationships::Relationship.new(
198
+ id: next_rid(rels), type: rel_type, target: target,
199
+ )
200
+ end
201
+
202
+ # Legacy alias — calls ensure_content_type (same behavior)
203
+ alias ensure_part_registered ensure_content_type
204
+
156
205
  def inject_image_parts(content, content_types, document_rels)
157
206
  return unless document&.image_parts && !document.image_parts.empty?
158
207
 
@@ -165,8 +214,15 @@ document_rels)
165
214
  )
166
215
  end
167
216
 
168
- document.image_parts.each do |r_id, image_data|
217
+ document.image_parts.each_value do |image_data|
169
218
  content["word/#{image_data[:target]}"] = image_data[:data]
219
+ end
220
+
221
+ return if allocator
222
+
223
+ document.image_parts.each do |r_id, image_data|
224
+ next if document_rels.relationships.any? { |r| r.target == image_data[:target] }
225
+
170
226
  document_rels.relationships << Ooxml::Relationships::Relationship.new(
171
227
  id: r_id,
172
228
  type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
@@ -200,24 +256,11 @@ document_rels)
200
256
  def inject_bibliography(content_types, document_rels)
201
257
  return unless document&.bibliography_sources
202
258
 
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
- )
259
+ ensure_content_type(content_types, document_rels,
260
+ part_name: "/word/sources.xml",
261
+ content_type: "application/vnd.openxmlformats-officedocument.bibliography+xml",
262
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/bibliography",
263
+ target: "sources.xml")
221
264
  end
222
265
 
223
266
  def inject_custom_properties(content_types, package_rels)
@@ -232,15 +275,15 @@ document_rels)
232
275
  )
233
276
  end
234
277
 
235
- unless package_rels.relationships.any? do |r|
278
+ return if package_rels.relationships.any? do |r|
236
279
  r.type.to_s.include?("officeDocument/2006/relationships/custom-properties")
237
280
  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
281
+
282
+ package_rels.relationships << Ooxml::Relationships::Relationship.new(
283
+ id: next_rid(package_rels),
284
+ type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
285
+ target: "docProps/custom.xml",
286
+ )
244
287
  end
245
288
 
246
289
  def inject_custom_xml(content_types)
@@ -265,17 +308,23 @@ document_rels)
265
308
  counter = 0
266
309
  document.headers.each_key do |type|
267
310
  counter += 1
268
- r_id = "rIdHeader#{counter}"
311
+ target = "header#{counter}.xml"
269
312
 
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
- )
313
+ unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
314
+ content_types.overrides << Uniword::ContentTypes::Override.new(
315
+ part_name: "/word/#{target}",
316
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
317
+ )
318
+ end
319
+
320
+ next if allocator
321
+ next if document_rels.relationships.any? { |r| r.target == target }
274
322
 
323
+ r_id = next_rid(document_rels)
275
324
  document_rels.relationships << Ooxml::Relationships::Relationship.new(
276
325
  id: r_id,
277
326
  type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
278
- target: "header#{counter}.xml",
327
+ target: target,
279
328
  )
280
329
 
281
330
  wire_header_reference(type, r_id)
@@ -288,17 +337,23 @@ document_rels)
288
337
  counter = 0
289
338
  document.footers.each_key do |type|
290
339
  counter += 1
291
- r_id = "rIdFooter#{counter}"
340
+ target = "footer#{counter}.xml"
292
341
 
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
- )
342
+ unless content_types.overrides.any? { |o| o.part_name == "/word/#{target}" }
343
+ content_types.overrides << Uniword::ContentTypes::Override.new(
344
+ part_name: "/word/#{target}",
345
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
346
+ )
347
+ end
348
+
349
+ next if allocator
350
+ next if document_rels.relationships.any? { |r| r.target == target }
297
351
 
352
+ r_id = next_rid(document_rels)
298
353
  document_rels.relationships << Ooxml::Relationships::Relationship.new(
299
354
  id: r_id,
300
355
  type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
301
- target: "footer#{counter}.xml",
356
+ target: target,
302
357
  )
303
358
 
304
359
  wire_footer_reference(type, r_id)
@@ -317,104 +372,53 @@ document_rels)
317
372
  )
318
373
  end
319
374
 
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
375
+ next if allocator
376
+ next if document_rels.relationships.any? { |r| r.id == part[:r_id] }
377
+
378
+ document_rels.relationships << Ooxml::Relationships::Relationship.new(
379
+ id: part[:r_id],
380
+ type: part[:rel_type],
381
+ target: part[:target],
382
+ )
327
383
  end
328
384
  end
329
385
 
330
386
  def inject_notes(content_types, document_rels)
331
387
  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
388
+ ensure_content_type(content_types, document_rels,
389
+ part_name: "/word/footnotes.xml",
390
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
391
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",
392
+ target: "footnotes.xml")
350
393
  end
351
394
 
352
395
  return unless endnotes
353
396
 
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
- )
397
+ ensure_content_type(content_types, document_rels,
398
+ part_name: "/word/endnotes.xml",
399
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
400
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
401
+ target: "endnotes.xml")
372
402
  end
373
403
 
374
404
  def inject_theme(content_types, document_rels)
375
405
  return unless theme
376
406
 
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
- )
407
+ ensure_content_type(content_types, document_rels,
408
+ part_name: "/word/theme/theme1.xml",
409
+ content_type: "application/vnd.openxmlformats-officedocument.theme+xml",
410
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
411
+ target: "theme/theme1.xml")
395
412
  end
396
413
 
397
414
  def inject_numbering(content_types, document_rels)
398
415
  return unless numbering
399
416
 
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
- )
417
+ ensure_content_type(content_types, document_rels,
418
+ part_name: "/word/numbering.xml",
419
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
420
+ rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
421
+ target: "numbering.xml")
418
422
  end
419
423
 
420
424
  def inject_embeddings(content_types, document_rels)