uniword 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +223 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/config/ooxml/schemas/shared_types.yml +5 -4
  5. data/data/schemas/ecma/dc.xsd +118 -0
  6. data/data/schemas/ecma/dcmitype.xsd +50 -0
  7. data/data/schemas/ecma/dcterms.xsd +331 -0
  8. data/data/schemas/ecma/opc-coreProperties.xsd +2 -2
  9. data/data/schemas/ecma/xml.xsd +117 -0
  10. data/lib/uniword/builder/bibliography_builder.rb +1 -1
  11. data/lib/uniword/builder/chart_builder.rb +19 -9
  12. data/lib/uniword/builder/comment_anchorer.rb +221 -0
  13. data/lib/uniword/builder/document_builder.rb +97 -6
  14. data/lib/uniword/builder/image_builder.rb +6 -5
  15. data/lib/uniword/builder/run_utils.rb +2 -0
  16. data/lib/uniword/builder.rb +1 -0
  17. data/lib/uniword/cli/fonts_cli.rb +52 -0
  18. data/lib/uniword/cli/main.rb +110 -11
  19. data/lib/uniword/cli/page_cli.rb +85 -0
  20. data/lib/uniword/cli/styles_cli.rb +204 -0
  21. data/lib/uniword/cli/theme_cli.rb +88 -0
  22. data/lib/uniword/cli/toc_cli.rb +22 -2
  23. data/lib/uniword/comment.rb +20 -15
  24. data/lib/uniword/comment_range.rb +3 -3
  25. data/lib/uniword/comments_part.rb +51 -19
  26. data/lib/uniword/configuration/configuration_loader.rb +5 -3
  27. data/lib/uniword/configuration.rb +93 -1
  28. data/lib/uniword/content_types.rb +19 -39
  29. data/lib/uniword/document_factory.rb +9 -1
  30. data/lib/uniword/document_writer.rb +16 -5
  31. data/lib/uniword/docx/chart_part.rb +34 -0
  32. data/lib/uniword/docx/custom_xml_item.rb +83 -0
  33. data/lib/uniword/docx/header_footer_part.rb +140 -0
  34. data/lib/uniword/docx/header_footer_part_collection.rb +135 -0
  35. data/lib/uniword/docx/header_footer_view.rb +225 -0
  36. data/lib/uniword/docx/id_allocator.rb +130 -36
  37. data/lib/uniword/docx/package.rb +223 -35
  38. data/lib/uniword/docx/package_defaults.rb +58 -78
  39. data/lib/uniword/docx/package_integrity_checker.rb +312 -0
  40. data/lib/uniword/docx/package_serialization.rb +103 -231
  41. data/lib/uniword/docx/part.rb +106 -0
  42. data/lib/uniword/docx/part_collection.rb +123 -0
  43. data/lib/uniword/docx/reconciler/body.rb +59 -68
  44. data/lib/uniword/docx/reconciler/fix.rb +46 -0
  45. data/lib/uniword/docx/reconciler/fix_codes.rb +37 -30
  46. data/lib/uniword/docx/reconciler/helpers.rb +9 -1
  47. data/lib/uniword/docx/reconciler/notes.rb +17 -8
  48. data/lib/uniword/docx/reconciler/package_structure.rb +169 -221
  49. data/lib/uniword/docx/reconciler/parts.rb +36 -13
  50. data/lib/uniword/docx/reconciler/referential_integrity.rb +297 -123
  51. data/lib/uniword/docx/reconciler/tables.rb +78 -16
  52. data/lib/uniword/docx/reconciler/theme.rb +7 -4
  53. data/lib/uniword/docx/reconciler.rb +36 -14
  54. data/lib/uniword/docx.rb +10 -0
  55. data/lib/uniword/drawingml/blip.rb +0 -1
  56. data/lib/uniword/drawingml/color_scheme.rb +5 -6
  57. data/lib/uniword/drawingml/font_scheme.rb +4 -8
  58. data/lib/uniword/drawingml/theme.rb +12 -6
  59. data/lib/uniword/errors.rb +8 -1
  60. data/lib/uniword/footer.rb +4 -0
  61. data/lib/uniword/header.rb +4 -0
  62. data/lib/uniword/hyperlink.rb +1 -1
  63. data/lib/uniword/image.rb +6 -6
  64. data/lib/uniword/lazy_loader.rb +1 -2
  65. data/lib/uniword/model_attribute_access.rb +20 -4
  66. data/lib/uniword/ooxml/dotx_package.rb +42 -2
  67. data/lib/uniword/ooxml/part_definition.rb +171 -0
  68. data/lib/uniword/ooxml/part_registry.rb +311 -0
  69. data/lib/uniword/ooxml/relationships/image_relationship.rb +1 -1
  70. data/lib/uniword/ooxml/relationships/package_relationships.rb +20 -22
  71. data/lib/uniword/ooxml/schema/element_serializer.rb +8 -7
  72. data/lib/uniword/ooxml/types/hex_color_value.rb +38 -0
  73. data/lib/uniword/ooxml/types/ooxml_boolean.rb +27 -14
  74. data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +21 -16
  75. data/lib/uniword/ooxml/types/theme_color_value.rb +22 -0
  76. data/lib/uniword/ooxml/types/unsigned_decimal_number.rb +30 -0
  77. data/lib/uniword/ooxml/types.rb +10 -0
  78. data/lib/uniword/ooxml.rb +2 -0
  79. data/lib/uniword/properties/alignment.rb +8 -3
  80. data/lib/uniword/properties/border.rb +52 -6
  81. data/lib/uniword/properties/cell_vertical_align.rb +4 -1
  82. data/lib/uniword/properties/color_value.rb +8 -3
  83. data/lib/uniword/properties/highlight.rb +9 -5
  84. data/lib/uniword/properties/shading.rb +4 -3
  85. data/lib/uniword/properties/tab_stop.rb +6 -1
  86. data/lib/uniword/properties/table_justification.rb +4 -1
  87. data/lib/uniword/properties/underline.rb +4 -2
  88. data/lib/uniword/properties/vertical_align.rb +6 -3
  89. data/lib/uniword/review/review_manager.rb +23 -9
  90. data/lib/uniword/revision.rb +9 -9
  91. data/lib/uniword/schema/model_generator.rb +31 -15
  92. data/lib/uniword/shared_types/hex_color.rb +4 -1
  93. data/lib/uniword/shared_types/pixel_measure.rb +4 -1
  94. data/lib/uniword/shared_types/point_measure.rb +4 -1
  95. data/lib/uniword/shared_types/text_alignment.rb +6 -1
  96. data/lib/uniword/shared_types/twips_measure.rb +4 -1
  97. data/lib/uniword/spreadsheetml/phonetic_pr.rb +1 -1
  98. data/lib/uniword/spreadsheetml/shared_string_table.rb +2 -2
  99. data/lib/uniword/spreadsheetml/table_formula.rb +1 -1
  100. data/lib/uniword/template/variable_resolver.rb +40 -7
  101. data/lib/uniword/themes/theme_transformation.rb +55 -41
  102. data/lib/uniword/toc/toc_generator.rb +2 -0
  103. data/lib/uniword/validation/engine.rb +35 -0
  104. data/lib/uniword/validation/link_checker.rb +1 -1
  105. data/lib/uniword/validation/opc_validator.rb +0 -1
  106. data/lib/uniword/validation/report/layer_result.rb +0 -1
  107. data/lib/uniword/validation/report/terminal_formatter.rb +0 -1
  108. data/lib/uniword/validation/report/verification_report.rb +0 -2
  109. data/lib/uniword/validation/report.rb +20 -0
  110. data/lib/uniword/validation/rules/base.rb +10 -2
  111. data/lib/uniword/validation/rules/bookmark_pairing_rule.rb +38 -0
  112. data/lib/uniword/validation/rules/bookmark_uniqueness_rule.rb +48 -0
  113. data/lib/uniword/validation/rules/bookmarks_rule.rb +0 -2
  114. data/lib/uniword/validation/rules/content_types_coverage_rule.rb +0 -2
  115. data/lib/uniword/validation/rules/core_properties_namespace_rule.rb +0 -2
  116. data/lib/uniword/validation/rules/document_body_rule.rb +25 -0
  117. data/lib/uniword/validation/rules/document_context.rb +7 -0
  118. data/lib/uniword/validation/rules/empty_paragraphs_rule.rb +27 -0
  119. data/lib/uniword/validation/rules/font_table_signature_rule.rb +0 -2
  120. data/lib/uniword/validation/rules/fonts_rule.rb +0 -2
  121. data/lib/uniword/validation/rules/footnotes_rule.rb +0 -2
  122. data/lib/uniword/validation/rules/headers_footers_rule.rb +0 -2
  123. data/lib/uniword/validation/rules/images_rule.rb +0 -2
  124. data/lib/uniword/validation/rules/mc_ignorable_namespace_rule.rb +0 -2
  125. data/lib/uniword/validation/rules/model_context.rb +32 -0
  126. data/lib/uniword/validation/rules/model_rule.rb +34 -0
  127. data/lib/uniword/validation/rules/numbering_preservation_rule.rb +0 -2
  128. data/lib/uniword/validation/rules/numbering_rule.rb +0 -2
  129. data/lib/uniword/validation/rules/relationship_integrity_rule.rb +0 -1
  130. data/lib/uniword/validation/rules/rsid_rule.rb +0 -2
  131. data/lib/uniword/validation/rules/section_properties_rule.rb +0 -2
  132. data/lib/uniword/validation/rules/settings_rule.rb +0 -2
  133. data/lib/uniword/validation/rules/settings_values_rule.rb +0 -2
  134. data/lib/uniword/validation/rules/style_references_rule.rb +0 -2
  135. data/lib/uniword/validation/rules/table_grid_rule.rb +27 -0
  136. data/lib/uniword/validation/rules/table_properties_rule.rb +27 -0
  137. data/lib/uniword/validation/rules/tables_rule.rb +0 -2
  138. data/lib/uniword/validation/rules/theme_completeness_rule.rb +0 -2
  139. data/lib/uniword/validation/rules/theme_rule.rb +0 -2
  140. data/lib/uniword/validation/rules.rb +61 -23
  141. data/lib/uniword/validation/schema_registry.rb +11 -0
  142. data/lib/uniword/validation/validators/document_semantics_validator.rb +1 -9
  143. data/lib/uniword/validation/validators/xml_schema_validator.rb +0 -2
  144. data/lib/uniword/validation/validators.rb +0 -9
  145. data/lib/uniword/validation/verify_orchestrator.rb +0 -7
  146. data/lib/uniword/validation.rb +7 -2
  147. data/lib/uniword/version.rb +1 -1
  148. data/lib/uniword/vml/imagedata.rb +0 -1
  149. data/lib/uniword/wordprocessingml/attached_template.rb +0 -1
  150. data/lib/uniword/wordprocessingml/deleted_text.rb +1 -1
  151. data/lib/uniword/wordprocessingml/document_root.rb +211 -21
  152. data/lib/uniword/wordprocessingml/endnotes.rb +4 -0
  153. data/lib/uniword/wordprocessingml/font_replacer.rb +147 -0
  154. data/lib/uniword/wordprocessingml/footnotes.rb +4 -0
  155. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +0 -1
  156. data/lib/uniword/wordprocessingml/hyperlink.rb +0 -1
  157. data/lib/uniword/wordprocessingml/level.rb +6 -20
  158. data/lib/uniword/wordprocessingml/math_pr.rb +0 -11
  159. data/lib/uniword/wordprocessingml/numbering_definition.rb +16 -8
  160. data/lib/uniword/wordprocessingml/page_setup.rb +181 -0
  161. data/lib/uniword/wordprocessingml/paragraph_properties.rb +33 -28
  162. data/lib/uniword/wordprocessingml/rsids.rb +0 -2
  163. data/lib/uniword/wordprocessingml/run.rb +3 -0
  164. data/lib/uniword/wordprocessingml/section_properties.rb +2 -2
  165. data/lib/uniword/wordprocessingml/settings.rb +8 -34
  166. data/lib/uniword/wordprocessingml/shape_defaults.rb +0 -1
  167. data/lib/uniword/wordprocessingml/style.rb +2 -1
  168. data/lib/uniword/wordprocessingml/style_cleanup.rb +198 -0
  169. data/lib/uniword/wordprocessingml/table_cell_properties.rb +6 -6
  170. data/lib/uniword/wordprocessingml/update_fields.rb +27 -0
  171. data/lib/uniword/wordprocessingml/w14_attributes.rb +30 -2
  172. data/lib/uniword/wordprocessingml.rb +4 -0
  173. data/lib/uniword.rb +27 -2
  174. metadata +38 -19
  175. data/config/validation_rules.yml +0 -60
  176. data/config/warning_rules.yml +0 -57
  177. data/lib/uniword/validation/document_validator.rb +0 -272
  178. data/lib/uniword/validation/structural_validator.rb +0 -116
  179. data/lib/uniword/validation/validators/content_type_validator.rb +0 -165
  180. data/lib/uniword/validation/validators/file_structure_validator.rb +0 -104
  181. data/lib/uniword/validation/validators/ooxml_part_validator.rb +0 -128
  182. data/lib/uniword/validation/validators/relationship_validator.rb +0 -147
  183. data/lib/uniword/validation/validators/zip_integrity_validator.rb +0 -110
  184. data/lib/uniword/validators/element_validator.rb +0 -93
  185. data/lib/uniword/validators/paragraph_validator.rb +0 -116
  186. data/lib/uniword/validators/table_validator.rb +0 -134
  187. data/lib/uniword/validators.rb +0 -9
  188. data/lib/uniword/warnings/warning.rb +0 -130
  189. data/lib/uniword/warnings/warning_collector.rb +0 -234
  190. data/lib/uniword/warnings/warning_report.rb +0 -159
  191. data/lib/uniword/warnings.rb +0 -9
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ # Keyed collection of Docx::Part objects.
6
+ #
7
+ # Backs +chart_parts+ (keyed by relationship id) and +embeddings+
8
+ # (keyed by relationship target). Keeps the legacy Hash-like
9
+ # access patterns working: assignment accepts Part objects, the
10
+ # former raw hashes ({ xml:, target: }) and raw content Strings,
11
+ # normalizing them into Part objects.
12
+ #
13
+ # @example
14
+ # parts = PartCollection.new(:r_id)
15
+ # parts["rIdChart1"] = { xml: "...", target: "charts/chart1.xml" }
16
+ # parts.values.first[:target] # => "charts/chart1.xml"
17
+ class PartCollection
18
+ include Enumerable
19
+
20
+ # @param key_attribute [Symbol] part attribute serving as the
21
+ # collection key (:r_id for charts, :target for embeddings)
22
+ # @param part_class [Class] Part subclass used to wrap raw values
23
+ def initialize(key_attribute, part_class)
24
+ @key_attribute = key_attribute
25
+ @part_class = part_class
26
+ @parts = {}
27
+ end
28
+
29
+ # @param key [String] collection key (rId or target)
30
+ # @return [Part, nil]
31
+ def [](key)
32
+ @parts[key]
33
+ end
34
+
35
+ # Store a part, normalizing raw hashes and raw content.
36
+ #
37
+ # @param key [String] collection key
38
+ # @param value [Part, Hash, String]
39
+ def []=(key, value)
40
+ @parts[key] = wrap(key, value)
41
+ end
42
+
43
+ # Iterate over [key, Part] pairs.
44
+ def each(&block)
45
+ @parts.each(&block)
46
+ end
47
+
48
+ # Iterate over collection keys.
49
+ def each_key(&block)
50
+ @parts.each_key(&block)
51
+ end
52
+
53
+ # Iterate over stored parts.
54
+ def each_value(&block)
55
+ @parts.each_value(&block)
56
+ end
57
+
58
+ # @return [Array<Part>]
59
+ def values
60
+ @parts.values
61
+ end
62
+
63
+ # @return [Array<String>]
64
+ def keys
65
+ @parts.keys
66
+ end
67
+
68
+ # @return [Integer]
69
+ def size
70
+ @parts.size
71
+ end
72
+
73
+ # @return [Boolean]
74
+ def empty?
75
+ @parts.empty?
76
+ end
77
+
78
+ # @return [Boolean]
79
+ def key?(key)
80
+ @parts.key?(key)
81
+ end
82
+
83
+ # @return [Part, nil] the removed part
84
+ def delete(key)
85
+ @parts.delete(key)
86
+ end
87
+
88
+ # Replace the whole collection from a Hash (or another
89
+ # PartCollection); nil clears it.
90
+ #
91
+ # @param value [Hash, PartCollection, nil]
92
+ # @return [void]
93
+ def replace_all(value)
94
+ @parts.clear
95
+ return if value.nil?
96
+
97
+ value.each { |key, part| self[key] = part }
98
+ end
99
+
100
+ private
101
+
102
+ def wrap(key, value)
103
+ case value
104
+ when Part
105
+ value
106
+ when Hash
107
+ @part_class.new(
108
+ target: value[:target], content: value[:content] || value[:xml],
109
+ ).tap { |part| assign_key(part, key) }
110
+ else
111
+ @part_class.new(content: value).tap { |part| assign_key(part, key) }
112
+ end
113
+ end
114
+
115
+ def assign_key(part, key)
116
+ case @key_attribute
117
+ when :r_id then part.r_id ||= key.to_s
118
+ when :target then part.target ||= key.to_s
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -21,7 +21,8 @@ module Uniword
21
21
  doc_grid: Wordprocessingml::PageDefaults.default_doc_grid,
22
22
  )
23
23
  record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
24
- "Added default section properties with US Letter page size")
24
+ "Added default section properties with US Letter page size",
25
+ part: "word/document.xml")
25
26
  return
26
27
  end
27
28
 
@@ -41,70 +42,82 @@ module Uniword
41
42
  end
42
43
  if fixed
43
44
  record_fix(FixCodes::SECTION_PROPERTIES_DEFAULTED,
44
- "Filled missing pgSz/pgMar/cols in existing section properties")
45
+ "Filled missing pgSz/pgMar/cols in existing section properties",
46
+ part: "word/document.xml")
45
47
  end
46
48
  end
47
49
 
48
50
  def reconcile_headers_footers
51
+ parts = package.document&.header_footer_parts
49
52
  ignorable = Ooxml::Types::McIgnorable.new(FULL_IGNORABLE)
50
- set_header_footer_ignorable(package.document&.headers, ignorable)
51
- set_header_footer_ignorable(package.document&.footers, ignorable)
52
53
 
53
- unless allocator
54
- rsid = generate_rsid
55
- backfill_header_footer_parts(package.document&.headers, rsid, "hdr")
56
- backfill_header_footer_parts(package.document&.footers, rsid, "ftr")
54
+ parts&.each do |part|
55
+ next unless part.content
56
+ next if part.loaded?
57
57
 
58
- parts = package.document&.header_footer_parts
59
- parts&.each_with_index do |part, pidx|
60
- backfill_paragraphs(part[:content].paragraphs, rsid, "hfp:#{pidx}")
61
- end
58
+ part.content.mc_ignorable = ignorable
59
+ end
60
+
61
+ unless builder_managed?
62
+ backfill_header_footer_paragraphs(parts, generate_rsid)
62
63
  end
63
64
 
64
- wire_builder_headers_footers
65
+ wire_header_footer_parts
65
66
  end
66
67
 
67
68
  private
68
69
 
69
- def set_header_footer_ignorable(parts, ignorable)
70
- return unless parts
71
-
72
- parts.each_value { |part| part.mc_ignorable = ignorable }
73
- end
74
-
75
- def backfill_header_footer_parts(parts, rsid, prefix)
70
+ # Assign rsid/paraId defaults to header/footer paragraphs
71
+ # (legacy, non-builder-managed path only). Seeds preserve the
72
+ # historic per-kind ("hdr:N"/"ftr:N") and per-store ("hfp:N")
73
+ # schemes so generated IDs stay stable across the unified store.
74
+ def backfill_header_footer_paragraphs(parts, rsid)
76
75
  return unless parts
77
76
 
78
- parts.each_with_index do |(_, part), pidx|
79
- backfill_paragraphs(part.paragraphs, rsid, "#{prefix}:#{pidx}")
77
+ fresh_counts = Hash.new(0)
78
+ parts.each_with_index do |part, pidx|
79
+ next unless part.content
80
+
81
+ if part.loaded?
82
+ backfill_paragraphs(part.content.paragraphs, rsid, "hfp:#{pidx}")
83
+ else
84
+ abbrev = part.kind == :footer ? "ftr" : "hdr"
85
+ idx = fresh_counts[part.kind]
86
+ fresh_counts[part.kind] += 1
87
+ backfill_paragraphs(part.content.paragraphs, rsid,
88
+ "#{abbrev}:#{idx}")
89
+ end
80
90
  end
81
91
  end
82
92
 
83
- HEADER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
84
- FOOTER_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
85
-
86
- # Wire builder-path headers/footers into document_rels and sectPr
87
- # during reconciliation so referential integrity checks see valid rIds.
88
- def wire_builder_headers_footers
93
+ # Wire builder-added (fresh) header/footer parts into
94
+ # document_rels and sectPr during reconciliation — the single
95
+ # wiring implementation. Loaded parts keep the relationships
96
+ # and section references they arrived with. rIds come from the
97
+ # allocator, the single rId authority.
98
+ def wire_header_footer_parts
89
99
  doc = package.document
90
100
  return unless doc&.body
91
- return unless doc.headers || doc.footers
101
+
102
+ parts = doc.header_footer_parts
103
+ return if parts.nil? || parts.empty?
92
104
 
93
105
  rels = package.document_rels
94
106
  return unless rels
95
107
 
96
- counter = 0
97
- wire_parts_to_rels(doc.headers, rels, HEADER_REL_TYPE,
98
- "header", counter) do |type, r_id|
99
- wire_sect_pr_reference(doc, :header, type, r_id)
100
- end
108
+ wired = false
109
+ parts.each do |part|
110
+ next if part.loaded? || part.target.nil?
101
111
 
102
- counter = 0
103
- wire_parts_to_rels(doc.footers, rels, FOOTER_REL_TYPE,
104
- "footer", counter) do |type, r_id|
105
- wire_sect_pr_reference(doc, :footer, type, r_id)
112
+ r_id = allocator.alloc_rid(target: part.target,
113
+ type: part.rel_type)
114
+ part.r_id = r_id
115
+ wire_sect_pr_reference(doc, part.kind, part.type, r_id) if part.type
116
+ wired = true
106
117
  end
107
118
 
119
+ return unless wired
120
+
108
121
  # Clear element_order so header/footer references serialize correctly.
109
122
  # Ordered mode only outputs elements in element_order; the template's
110
123
  # order may not include newly-added references.
@@ -112,33 +125,6 @@ module Uniword
112
125
  sect_pr.element_order = nil if sect_pr&.element_order
113
126
  end
114
127
 
115
- def wire_parts_to_rels(parts, rels, rel_type, file_prefix, counter)
116
- return unless parts && !parts.empty?
117
-
118
- parts.each_key do |type|
119
- counter += 1
120
- target = "#{file_prefix}#{counter}.xml"
121
- r_id = if allocator
122
- allocator.alloc_rid(target: target, type: rel_type)
123
- else
124
- find_or_create_rel(rels, rel_type, target)
125
- end
126
- yield type, r_id
127
- end
128
- end
129
-
130
- def find_or_create_rel(rels, rel_type, target)
131
- existing = rels.relationships.find { |r| r.target == target }
132
- return existing.id if existing
133
-
134
- r_id = Ooxml::Relationships::PackageRelationships
135
- .next_available_rid(rels)
136
- rels.relationships << Ooxml::Relationships::Relationship.new(
137
- id: r_id, type: rel_type, target: target,
138
- )
139
- r_id
140
- end
141
-
142
128
  def wire_sect_pr_reference(doc, kind, type, r_id)
143
129
  sect_pr = doc.body.section_properties
144
130
  return unless sect_pr
@@ -169,14 +155,15 @@ module Uniword
169
155
  doc = package.document
170
156
  set_mc_ignorable(doc, prefixes: FULL_IGNORABLE)
171
157
 
172
- record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body")
158
+ record_fix(FixCodes::MC_IGNORABLE, "Added mc:Ignorable to document body",
159
+ part: "word/document.xml")
173
160
 
174
161
  body = doc.body
175
162
  rsid = generate_rsid
176
163
 
177
164
  body.paragraphs.each_with_index do |para, idx|
178
165
  strip_empty_runs(para)
179
- next if allocator
166
+ next if builder_managed?
180
167
 
181
168
  para.rsid_r ||= rsid
182
169
  para.rsid_r_default ||= "00000000"
@@ -184,7 +171,11 @@ module Uniword
184
171
  para.text_id ||= "77777777"
185
172
  end
186
173
 
187
- record_fix(FixCodes::PARAGRAPH_BACKFILL, "Assigned rsid and paraId to paragraphs") unless allocator
174
+ unless builder_managed?
175
+ record_fix(FixCodes::PARAGRAPH_BACKFILL,
176
+ "Assigned rsid and paraId to paragraphs",
177
+ part: "word/document.xml")
178
+ end
188
179
 
189
180
  sect_pr = body.section_properties
190
181
  return unless sect_pr
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Docx
5
+ class Reconciler
6
+ # A single repair applied by the Reconciler during save.
7
+ #
8
+ # Immutable value object exposed as the reconciliation report via
9
+ # `Package#applied_fixes` after a save. The Reconciler stays the only
10
+ # mutating pass; this report is a by-product value, not a global.
11
+ #
12
+ # @example Inspect fixes after save
13
+ # package.to_file("out.docx")
14
+ # package.applied_fixes.each do |fix|
15
+ # puts "#{fix.code} [#{fix.part}] #{fix.message}"
16
+ # end
17
+ class Fix
18
+ # @return [String] Fix code (see Reconciler::FixCodes)
19
+ attr_reader :code
20
+
21
+ # @return [String] Human-readable description of the repair
22
+ attr_reader :message
23
+
24
+ # @return [String, nil] Package part the repair applies to
25
+ attr_reader :part
26
+
27
+ # Create a fix record.
28
+ #
29
+ # @param code [String] Fix code (a FixCodes constant value)
30
+ # @param message [String] Human-readable description of the repair
31
+ # @param part [String, nil] Package part the repair applies to
32
+ def initialize(code:, message:, part: nil)
33
+ @code = code
34
+ @message = message
35
+ @part = part
36
+ end
37
+
38
+ # @return [String] Compact single-line representation
39
+ def to_s
40
+ location = part ? " [#{part}]" : ""
41
+ "#{code}#{location}: #{message}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -7,14 +7,15 @@ module Uniword
7
7
  #
8
8
  # Every `record_fix` call site references a constant from this module
9
9
  # instead of a bare string literal. Adding a new fix category means
10
- # adding one constant here, not hunting for the next free letter.
10
+ # adding one constant here with the next free code.
11
11
  #
12
- # Wire format is preserved external validation rules and audit-log
13
- # consumers that pattern-match on "R1".."R16" continue to work.
14
- #
15
- # Known debt: R10 is overloaded for several distinct concerns
16
- # (note defs, table structure, style defaults, dangling refs).
17
- # Splitting requires consumer coordination.
12
+ # One code per concern: each constant has a unique, self-describing
13
+ # wire value. Codes "R1".."R16" are frozen — external validation rules
14
+ # (lib/uniword/validation/rules) and audit-log consumers pattern-match
15
+ # on them, so the original concerns keep their historic codes. Codes
16
+ # "R17" and up were introduced when the overloaded "R10" (and the
17
+ # remaining shared codes "R4"/"R11"/"R12"/"R13") were split into
18
+ # per-concern values.
18
19
  module FixCodes
19
20
  # Settings / package structure
20
21
  MC_IGNORABLE = "R1"
@@ -29,17 +30,12 @@ module Uniword
29
30
  CONTENT_TYPES_ASSEMBLED = "R7"
30
31
  APP_PROPERTIES_ENSURED = "R8"
31
32
 
32
- # Notes — pair creation
33
+ # Notes — pair creation and definition integrity
33
34
  NOTE_PAIR_CREATED = "R9"
34
35
  NOTE_DEFINITION_CREATED = "R10"
35
36
  NOTE_INVALID_TYPE_STRIPPED = "R15"
36
37
  NOTE_DUPLICATE_ID_REMOVED = "R16"
37
38
 
38
- # Notes — referential integrity warnings and removals
39
- DANGLING_NOTE_REFERENCE_WARNING = "R9"
40
- DANGLING_NOTE_REFERENCE_REMOVED = "R10"
41
- DANGLING_HYPERLINK_WARNING = "R9"
42
-
43
39
  # Body
44
40
  SECTION_PROPERTIES_DEFAULTED = "R11"
45
41
  PARAGRAPH_BACKFILL = "R12"
@@ -48,26 +44,37 @@ module Uniword
48
44
  FONT_TABLE_CREATED = "R13"
49
45
  CORE_PROPERTIES_REBUILT = "R14"
50
46
 
51
- # Styles (currently shares R10 with other concerns see debt note above)
52
- STYLE_DEFAULTS_ADDED = "R10"
53
- SEMI_HIDDEN_ADDED = "R10"
47
+ # Referential integritydangling-reference repairs (split from
48
+ # the overloaded R10/R4/R11/R12 codes; one code per concern)
49
+ DANGLING_NUMBERING_REMOVED = "R17"
50
+ DANGLING_NOTE_REFERENCE_REMOVED = "R18"
51
+ DANGLING_STYLE_REFERENCE_REMOVED = "R19"
52
+ DANGLING_BASED_ON_REMOVED = "R20"
53
+ DANGLING_HYPERLINK_REMOVED = "R21"
54
+ DANGLING_HEADER_FOOTER_REMOVED = "R22"
55
+ DANGLING_DRAWING_REMOVED = "R23"
56
+ DANGLING_RELATIONSHIP_TARGET_REMOVED = "R32"
57
+
58
+ # Referential integrity — literal hyperlink targets promoted to
59
+ # proper External relationships (repairs Builder.hyperlink output)
60
+ HYPERLINK_RELATIONSHIP_CREATED = "R31"
61
+
62
+ # Styles
63
+ STYLE_DEFAULTS_ADDED = "R24"
64
+ SEMI_HIDDEN_ADDED = "R25"
54
65
 
55
- # Tables (currently shares R10)
56
- TABLE_STRUCTURE_RECONCILED = "R10"
57
- TABLE_CELL_PR_REORDERED = "R10"
58
- TABLE_CELL_DEFAULTS = "R12"
59
- TABLE_ROW_GRID_AFTER = "R13"
66
+ # Tables
67
+ TABLE_STRUCTURE_RECONCILED = "R26"
68
+ TABLE_CELL_PR_REORDERED = "R27"
69
+ TABLE_CELL_DEFAULTS = "R28"
70
+ TABLE_ROW_GRID_AFTER = "R29"
60
71
 
61
- # Referential integrity (currently shares R10)
62
- DANGLING_STYLE_REFERENCE_REMOVED = "R10"
63
- DANGLING_BASED_ON_REMOVED = "R10"
64
- DANGLING_NUMBERING_REMOVED = "R4"
65
- DANGLING_HYPERLINK_REMOVED = "R10"
66
- DANGLING_HEADER_FOOTER_REMOVED = "R11"
67
- DANGLING_DRAWING_REMOVED = "R12"
72
+ # Run cleanup
73
+ EMPTY_RUNS_STRIPPED = "R30"
68
74
 
69
- # Run cleanup (currently shares R10)
70
- EMPTY_RUNS_STRIPPED = "R10"
75
+ # Tables gridCol widths defaulted to equal shares of the
76
+ # section content width (Word's fallback behavior)
77
+ TABLE_GRID_COL_WIDTHS_DEFAULTED = "R33"
71
78
  end
72
79
  end
73
80
  end
@@ -85,7 +85,7 @@ module Uniword
85
85
  walk_body_paragraphs(package.document.body, &block)
86
86
 
87
87
  (package.document&.header_footer_parts || []).each do |part|
88
- (part[:content]&.paragraphs || []).each(&block)
88
+ (part.content&.paragraphs || []).each(&block)
89
89
  end
90
90
  end
91
91
 
@@ -295,6 +295,14 @@ module Uniword
295
295
  end
296
296
  end
297
297
 
298
+ # Package part path of the notes collection for the note type.
299
+ def notes_part_for(type)
300
+ case type
301
+ when :footnote then "word/footnotes.xml"
302
+ when :endnote then "word/endnotes.xml"
303
+ end
304
+ end
305
+
298
306
  def note_entries_for(notes, type)
299
307
  return [] unless notes
300
308
 
@@ -38,7 +38,8 @@ module Uniword
38
38
  )
39
39
  end
40
40
  record_fix(FixCodes::NOTE_DEFINITION_CREATED,
41
- "Created missing #{type}note definitions for ids=#{missing.join(', ')}")
41
+ "Created missing #{type}note definitions for ids=#{missing.join(', ')}",
42
+ part: notes_part_for(type))
42
43
  end
43
44
  end
44
45
 
@@ -70,11 +71,13 @@ module Uniword
70
71
  create_message:, pr_message:)
71
72
  if has_pr && !notes
72
73
  notes_setter.call(minimal_notes(type))
73
- record_fix(FixCodes::NOTE_PAIR_CREATED, create_message)
74
+ record_fix(FixCodes::NOTE_PAIR_CREATED, create_message,
75
+ part: notes_part_for(type))
74
76
  elsif notes && !has_pr
75
77
  package.settings ||= Wordprocessingml::Settings.new
76
78
  assign_note_pr(package.settings, type)
77
- record_fix(FixCodes::NOTE_PAIR_CREATED, pr_message)
79
+ record_fix(FixCodes::NOTE_PAIR_CREATED, pr_message,
80
+ part: "word/settings.xml")
78
81
  end
79
82
 
80
83
  current = notes_collection_for(type)
@@ -92,7 +95,7 @@ module Uniword
92
95
  strip_empty_runs_from_notes(entries)
93
96
  reorder_notes_by_reference(entries, type)
94
97
 
95
- unless allocator
98
+ unless builder_managed?
96
99
  prefix = type == :footnote ? "fn" : "en"
97
100
 
98
101
  entries.each_with_index do |entry, eidx|
@@ -182,7 +185,8 @@ module Uniword
182
185
  return if stripped.empty?
183
186
 
184
187
  record_fix(FixCodes::NOTE_INVALID_TYPE_STRIPPED,
185
- "Stripped invalid w:type from #{type}note ids=#{stripped.join(', ')}")
188
+ "Stripped invalid w:type from #{type}note ids=#{stripped.join(', ')}",
189
+ part: notes_part_for(type))
186
190
  end
187
191
 
188
192
  def deduplicate_note_ids(entries, type)
@@ -200,7 +204,8 @@ module Uniword
200
204
  return if dup_ids.empty?
201
205
 
202
206
  record_fix(FixCodes::NOTE_DUPLICATE_ID_REMOVED,
203
- "Removed duplicate #{type}note ids=#{dup_ids.join(', ')}")
207
+ "Removed duplicate #{type}note ids=#{dup_ids.join(', ')}",
208
+ part: notes_part_for(type))
204
209
  end
205
210
 
206
211
  def reorder_notes_by_reference(entries, type)
@@ -227,7 +232,9 @@ module Uniword
227
232
  structural.each { |e| entries << e }
228
233
  reordered.each { |e| entries << e }
229
234
 
230
- record_fix(FixCodes::NOTE_PAIR_CREATED, "Reordered #{type}notes by first reference in document body")
235
+ record_fix(FixCodes::NOTE_PAIR_CREATED,
236
+ "Reordered #{type}notes by first reference in document body",
237
+ part: notes_part_for(type))
231
238
  end
232
239
 
233
240
  def renumber_notes(entries, type)
@@ -255,7 +262,9 @@ module Uniword
255
262
  end
256
263
  end
257
264
 
258
- record_fix(FixCodes::NOTE_PAIR_CREATED, "Renumbered #{type}note IDs sequentially (#{id_map.size} changed)")
265
+ record_fix(FixCodes::NOTE_PAIR_CREATED,
266
+ "Renumbered #{type}note IDs sequentially (#{id_map.size} changed)",
267
+ part: notes_part_for(type))
259
268
  end
260
269
 
261
270
  def collect_note_reference_order(body, type)