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
@@ -15,7 +15,7 @@ module Uniword
15
15
  return unless tables
16
16
 
17
17
  tables.each_with_index do |tbl, idx|
18
- if allocator
18
+ if builder_managed?
19
19
  reconcile_table_cell_order_only(tbl, idx)
20
20
  else
21
21
  reconcile_single_table(tbl, idx)
@@ -25,14 +25,16 @@ module Uniword
25
25
 
26
26
  private
27
27
 
28
- # Allocator path: builders create complete tables.
29
- # Only fix element order issues and calculate gridAfter.
28
+ # Builder-managed path: builders create complete tables.
29
+ # Only fix element order issues, default gridCol widths, and
30
+ # calculate gridAfter.
30
31
  def reconcile_table_cell_order_only(tbl, idx)
31
32
  tbl.rows&.each do |row|
32
33
  row.cells&.each do |cell|
33
34
  reconcile_table_cell_order(cell)
34
35
  end
35
36
  end
37
+ reconcile_grid_col_widths(tbl)
36
38
  reconcile_grid_after(tbl)
37
39
  end
38
40
 
@@ -79,16 +81,11 @@ module Uniword
79
81
  fixed = true
80
82
  end
81
83
 
82
- tbl.grid&.columns&.each_with_index do |col, ci|
83
- next if col.width
84
+ reconcile_grid_col_widths(tbl)
84
85
 
85
- Uniword.logger&.warn do
86
- "Table #{idx}: gridCol[#{ci}] has no w:w — " \
87
- "table may render with incorrect column widths"
88
- end
89
- end
90
-
91
- record_fix(FixCodes::TABLE_STRUCTURE_RECONCILED, "Reconciled table structure for table #{idx}") if fixed
86
+ record_fix(FixCodes::TABLE_STRUCTURE_RECONCILED,
87
+ "Reconciled table structure for table #{idx}",
88
+ part: "word/document.xml") if fixed
92
89
 
93
90
  tbl.rows&.each do |row|
94
91
  row.cells&.each do |cell|
@@ -98,10 +95,14 @@ module Uniword
98
95
  cell_width: Uniword::Properties::CellWidth.new(w: 0, type: "auto"),
99
96
  )
100
97
  insert_element_order(cell, "tcPr", 0)
101
- record_fix(FixCodes::TABLE_CELL_DEFAULTS, "Added missing tcPr with tcW to table cell")
98
+ record_fix(FixCodes::TABLE_CELL_DEFAULTS,
99
+ "Added missing tcPr with tcW to table cell",
100
+ part: "word/document.xml")
102
101
  elsif tc_pr.cell_width.nil?
103
102
  tc_pr.cell_width = Uniword::Properties::CellWidth.new(w: 0, type: "auto")
104
- record_fix(FixCodes::TABLE_CELL_DEFAULTS, "Added missing tcW to table cell")
103
+ record_fix(FixCodes::TABLE_CELL_DEFAULTS,
104
+ "Added missing tcW to table cell",
105
+ part: "word/document.xml")
105
106
  end
106
107
 
107
108
  reconcile_table_cell_order(cell)
@@ -111,6 +112,63 @@ module Uniword
111
112
  reconcile_grid_after(tbl)
112
113
  end
113
114
 
115
+ # Default missing gridCol widths to an equal share of the
116
+ # section's content width (page width minus left/right margins) —
117
+ # the same fallback Word applies, keeping the package free of
118
+ # width-less grids. Explicit widths are kept; width-less columns
119
+ # share the remaining width equally.
120
+ def reconcile_grid_col_widths(tbl)
121
+ columns = tbl.grid&.columns
122
+ return unless columns&.any?
123
+
124
+ missing = columns.count { |col| col.width.nil? }
125
+ return if missing.zero?
126
+
127
+ default_missing_widths(columns, missing)
128
+ end
129
+
130
+ def default_missing_widths(columns, missing)
131
+ width = shared_column_width(columns, missing)
132
+ columns.each { |col| col.width ||= width }
133
+ record_fix(FixCodes::TABLE_GRID_COL_WIDTHS_DEFAULTED,
134
+ "Defaulted #{missing} gridCol width(s) to " \
135
+ "#{width} twips",
136
+ part: "word/document.xml")
137
+ end
138
+
139
+ # Equal content-width share for `missing` width-less columns
140
+ # after explicit widths take their part.
141
+ def shared_column_width(columns, missing)
142
+ content = section_content_width
143
+ explicit = columns.sum { |col| col.width || 0 }
144
+ remainder = [content - explicit, 0].max
145
+ (remainder.positive? ? remainder : content) / missing
146
+ end
147
+
148
+ # Content width (page width minus left/right margins) in twips.
149
+ # Tables do not track section ownership, so the body-level sectPr
150
+ # governs; falls back to US Letter defaults.
151
+ def section_content_width
152
+ left, right = section_side_margins
153
+ [section_page_width - left - right, 1].max
154
+ end
155
+
156
+ def section_page_width
157
+ sect_pr = package.document&.body&.section_properties
158
+ sect_pr&.page_size&.width ||
159
+ Wordprocessingml::PageDefaults.default_page_size.width
160
+ end
161
+
162
+ def section_side_margins
163
+ margins = section_properties_margins
164
+ defaults = Wordprocessingml::PageDefaults.default_page_margins
165
+ [margins&.left || defaults.left, margins&.right || defaults.right]
166
+ end
167
+
168
+ def section_properties_margins
169
+ package.document&.body&.section_properties&.page_margins
170
+ end
171
+
114
172
  def reconcile_table_cell_order(cell)
115
173
  order = cell.element_order
116
174
  return unless order && !order.empty?
@@ -124,7 +182,9 @@ module Uniword
124
182
  tcPr_entry = order.delete_at(tcPr_idx)
125
183
  order.insert(first_p_idx, tcPr_entry)
126
184
 
127
- record_fix(FixCodes::TABLE_CELL_PR_REORDERED, "Moved tcPr before p in table cell")
185
+ record_fix(FixCodes::TABLE_CELL_PR_REORDERED,
186
+ "Moved tcPr before p in table cell",
187
+ part: "word/document.xml")
128
188
  end
129
189
 
130
190
  def reconcile_grid_after(tbl)
@@ -153,7 +213,9 @@ module Uniword
153
213
  row_props.grid_after ||= Wordprocessingml::GridAfter.new
154
214
  row_props.grid_after.value = missing
155
215
  end
156
- record_fix(FixCodes::TABLE_ROW_GRID_AFTER, "Added gridAfter=#{missing} to table row (grid has #{grid_col_count} cols, row covers #{covered})")
216
+ record_fix(FixCodes::TABLE_ROW_GRID_AFTER,
217
+ "Added gridAfter=#{missing} to table row (grid has #{grid_col_count} cols, row covers #{covered})",
218
+ part: "word/document.xml")
157
219
  end
158
220
  end
159
221
  end
@@ -20,7 +20,9 @@ module Uniword
20
20
  word_theme = friendly.to_word_theme
21
21
  word_theme.name = "Office Theme"
22
22
  package.theme = word_theme
23
- record_fix(FixCodes::THEME_CREATED, "Created default theme with complete fmtScheme")
23
+ record_fix(FixCodes::THEME_CREATED,
24
+ "Created default theme with complete fmtScheme",
25
+ part: "word/theme/theme1.xml")
24
26
  rescue ArgumentError
25
27
  nil
26
28
  end
@@ -58,7 +60,8 @@ module Uniword
58
60
  return unless repaired
59
61
 
60
62
  record_fix(FixCodes::THEME_CREATED,
61
- "Repaired theme fmtScheme with minimum required content")
63
+ "Repaired theme fmtScheme with minimum required content",
64
+ part: "word/theme/theme1.xml")
62
65
  end
63
66
 
64
67
  private
@@ -100,7 +103,7 @@ module Uniword
100
103
  fills = Array(lst.solid_fills).dup
101
104
  while fills.size < 2
102
105
  fills << Drawingml::SolidFill.new(
103
- scheme_color: Drawingml::SchemeColor.new(
106
+ scheme_clr: Drawingml::SchemeColor.new(
104
107
  val: "accent#{fills.size + 1}"
105
108
  ),
106
109
  )
@@ -118,7 +121,7 @@ module Uniword
118
121
  lines << Drawingml::LineProperties.new(
119
122
  width: widths[idx] || 9525,
120
123
  solid_fill: Drawingml::SolidFill.new(
121
- scheme_color: Drawingml::SchemeColor.new(val: "accent#{idx + 1}"),
124
+ scheme_clr: Drawingml::SchemeColor.new(val: "accent#{idx + 1}"),
122
125
  ),
123
126
  )
124
127
  end
@@ -9,7 +9,8 @@ module Uniword
9
9
  VALID_NOTE_TYPES = %w[separator continuationSeparator
10
10
  footnoteSeparator continuationNotice].freeze
11
11
 
12
- # Sub-modules autoloaded — no require_relative for internal code.
12
+ # Sub-modules autoloaded — registered below, loaded on first use.
13
+ autoload :Fix, "#{__dir__}/reconciler/fix"
13
14
  autoload :FixCodes, "#{__dir__}/reconciler/fix_codes"
14
15
  autoload :Helpers, "#{__dir__}/reconciler/helpers"
15
16
  autoload :Notes, "#{__dir__}/reconciler/notes"
@@ -29,10 +30,16 @@ module Uniword
29
30
  include PackageStructure
30
31
  include Body
31
32
 
32
- def initialize(package, profile: nil, allocator: nil)
33
+ def initialize(package, profile: nil, allocator: nil,
34
+ builder_managed: nil)
33
35
  @package = package
34
36
  @profile = profile
35
37
  @allocator = allocator
38
+ @builder_managed = if builder_managed.nil?
39
+ !allocator.nil? || !package.allocator.nil?
40
+ else
41
+ builder_managed
42
+ end
36
43
  @applied_fixes = []
37
44
  end
38
45
 
@@ -71,23 +78,40 @@ module Uniword
71
78
  reconcile_referential_integrity
72
79
  end
73
80
 
74
- # Audit trail of fixes applied during reconciliation.
81
+ # Audit trail of repairs applied during reconciliation.
82
+ #
83
+ # @return [Array<Fix>] Fixes recorded by the most recent #reconcile
75
84
  attr_reader :applied_fixes
76
85
 
77
86
  private
78
87
 
79
88
  attr_reader :package, :profile
80
89
 
90
+ # The single rId/ID authority for this run. Falls back to an
91
+ # allocator populated from the package when the package carries
92
+ # none (direct Reconciler use outside Package#to_zip_content).
81
93
  def allocator
82
- @allocator || package.allocator
94
+ @allocator ||= package.allocator ||
95
+ IdAllocator.populate_from_package(package)
83
96
  end
84
97
 
85
- def record_fix(validity_rule, message)
86
- @applied_fixes << {
87
- validity_rule: validity_rule,
88
- message: message,
89
- timestamp: Time.now,
90
- }
98
+ # Whether the document's IDs were managed outside the reconciler
99
+ # (builder-created content, or a package loaded with an explicit
100
+ # allocator). Builder-managed documents get light-touch repairs;
101
+ # legacy documents get the full normalization repertoire (ID
102
+ # backfill, table normalization, note renumbering). rIds flow
103
+ # through the allocator either way. Decided at initialize time.
104
+ def builder_managed?
105
+ @builder_managed
106
+ end
107
+
108
+ # Record one applied repair as a Fix value object.
109
+ #
110
+ # @param code [String] Fix code (a FixCodes constant value)
111
+ # @param message [String] Human-readable description of the repair
112
+ # @param part [String, nil] Package part the repair applies to
113
+ def record_fix(code, message, part: nil)
114
+ @applied_fixes << Fix.new(code: code, message: message, part: part)
91
115
  end
92
116
 
93
117
  def clear_stored_namespace_plans
@@ -102,15 +126,13 @@ module Uniword
102
126
  package.app_properties,
103
127
  package.footnotes,
104
128
  package.endnotes,
129
+ package.comments,
105
130
  ].compact
106
131
 
107
132
  (package.document&.header_footer_parts || []).each do |part|
108
- parts << part[:content] if part[:content]
133
+ parts << part.content if part.content
109
134
  end
110
135
 
111
- (package.document&.headers&.values || []).each { |h| parts << h }
112
- (package.document&.footers&.values || []).each { |f| parts << f }
113
-
114
136
  parts.each do |part|
115
137
  part.import_declaration_plan = nil
116
138
  part.pending_plan_root_element = nil
data/lib/uniword/docx.rb CHANGED
@@ -8,8 +8,18 @@ module Uniword
8
8
  # document-level reconciliation, and user-defined requirements.
9
9
  module Docx
10
10
  autoload :IdAllocator, "#{__dir__}/docx/id_allocator"
11
+ autoload :Part, "#{__dir__}/docx/part"
12
+ autoload :ChartPart, "#{__dir__}/docx/chart_part"
13
+ autoload :HeaderFooterPart, "#{__dir__}/docx/header_footer_part"
14
+ autoload :CustomXmlItem, "#{__dir__}/docx/custom_xml_item"
15
+ autoload :PartCollection, "#{__dir__}/docx/part_collection"
16
+ autoload :HeaderFooterPartCollection,
17
+ "#{__dir__}/docx/header_footer_part_collection"
18
+ autoload :HeaderFooterView, "#{__dir__}/docx/header_footer_view"
11
19
  autoload :Package, "#{__dir__}/docx/package"
12
20
  autoload :PackageDefaults, "#{__dir__}/docx/package_defaults"
21
+ autoload :PackageIntegrityChecker,
22
+ "#{__dir__}/docx/package_integrity_checker"
13
23
  autoload :PackageSerialization, "#{__dir__}/docx/package_serialization"
14
24
  autoload :Profile, "#{__dir__}/docx/profile"
15
25
  autoload :DocumentStatistics, "#{__dir__}/docx/document_statistics"
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require "uniword/ooxml/types/relationship_id"
5
4
 
6
5
  module Uniword
7
6
  module Drawingml
@@ -399,14 +399,13 @@ module Uniword
399
399
 
400
400
  # Duplicate the color scheme
401
401
  #
402
+ # Deep copy via XML round-trip — preserves system-color entries
403
+ # (dk1/lt1 as windowText/window) and every color slot, not just
404
+ # the standard hex values.
405
+ #
402
406
  # @return [ColorScheme] A deep copy of this color scheme
403
407
  def dup
404
- new_scheme = ColorScheme.new(name: name)
405
- THEME_COLORS.each do |color_name|
406
- value = self[color_name]
407
- new_scheme[color_name] = value if value
408
- end
409
- new_scheme
408
+ self.class.from_xml(to_xml)
410
409
  end
411
410
  end
412
411
  end
@@ -225,16 +225,12 @@ module Uniword
225
225
 
226
226
  # Duplicate the font scheme
227
227
  #
228
+ # Deep copy via XML round-trip — preserves per-script font entries
229
+ # (Jpan/Hang/Arab/...) that the previous hand-rolled copy dropped.
230
+ #
228
231
  # @return [FontScheme] A deep copy of this font scheme
229
232
  def dup
230
- new_scheme = FontScheme.new(name: name)
231
- new_scheme.major_font = major_font
232
- new_scheme.minor_font = minor_font
233
- new_scheme.major_east_asian = major_east_asian if major_east_asian
234
- new_scheme.major_complex_script = major_complex_script if major_complex_script
235
- new_scheme.minor_east_asian = minor_east_asian if minor_east_asian
236
- new_scheme.minor_complex_script = minor_complex_script if minor_complex_script
237
- new_scheme
233
+ self.class.from_xml(to_xml)
238
234
  end
239
235
  end
240
236
  end
@@ -127,14 +127,20 @@ module Uniword
127
127
 
128
128
  # Duplicate the theme
129
129
  #
130
+ # Deep copy via XML round-trip: copies color scheme, font scheme,
131
+ # format scheme (fill/line/effect lists), and every other mapped
132
+ # part. The previous hand-rolled copy dropped fmt_scheme (and
133
+ # object_defaults/ext_lst/media_files), producing themes with
134
+ # empty fillStyleLst that Word rejects.
135
+ #
130
136
  # @return [Theme] A deep copy of this theme
131
137
  def dup
132
- new_theme = Theme.new(name: name)
133
- new_theme.color_scheme = color_scheme.dup if color_scheme
134
- new_theme.font_scheme = font_scheme.dup if font_scheme
135
- new_theme.variants = @variants.dup if @variants
136
- new_theme.source_file = @source_file
137
- new_theme
138
+ self.class.from_xml(to_xml).tap do |copy|
139
+ copy.variants = @variants&.dup
140
+ copy.source_file = @source_file
141
+ copy.media_files = @media_files
142
+ copy.raw_xml = @raw_xml
143
+ end
138
144
  end
139
145
 
140
146
  # Check if theme is valid
@@ -54,11 +54,14 @@ module Uniword
54
54
  class ValidationError < Error
55
55
  # @param element [Element] The element that failed validation
56
56
  # @param errors [Array<String>] Array of validation error messages
57
- def initialize(element, errors)
57
+ # @param issues [Array] Structured issues (e.g.
58
+ # Validation::Report::ValidationIssue with code, part, message)
59
+ def initialize(element, errors, issues: [])
58
60
  element_name = element.class.name.split("::").last
59
61
  super("Validation failed for #{element_name}: #{errors.join(', ')}")
60
62
  @element = element
61
63
  @errors = errors
64
+ @issues = issues
62
65
  end
63
66
 
64
67
  # @return [Element] The element that failed
@@ -66,6 +69,10 @@ module Uniword
66
69
 
67
70
  # @return [Array<String>] The validation errors
68
71
  attr_reader :errors
72
+
73
+ # @return [Array] Structured issues (code, part, message) — populated
74
+ # by the write-time package integrity gate
75
+ attr_reader :issues
69
76
  end
70
77
 
71
78
  # Raised when trying to write to a read-only document
@@ -12,6 +12,10 @@ module Uniword
12
12
  attribute :tables, Wordprocessingml::Table, collection: true,
13
13
  initialize_empty: true
14
14
 
15
+ # mc:Ignorable prefix list carried to the serialized
16
+ # Wordprocessingml footer part (set by the reconciler).
17
+ attr_accessor :mc_ignorable
18
+
15
19
  # Valid footer types
16
20
  TYPES = %w[default first even].freeze
17
21
 
@@ -12,6 +12,10 @@ module Uniword
12
12
  attribute :tables, Wordprocessingml::Table, collection: true,
13
13
  initialize_empty: true
14
14
 
15
+ # mc:Ignorable prefix list carried to the serialized
16
+ # Wordprocessingml header part (set by the reconciler).
17
+ attr_accessor :mc_ignorable
18
+
15
19
  # Valid header types
16
20
  TYPES = %w[default first even].freeze
17
21
 
@@ -15,7 +15,7 @@ module Uniword
15
15
  # link = Hyperlink.new(anchor: 'section1', text: 'Go to section')
16
16
  # link.internal? # => true
17
17
  class Hyperlink
18
- REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
18
+ REL_TYPE = Ooxml::PartRegistry.find_by_key(:hyperlink).rel_type
19
19
 
20
20
  attr_reader :url, :anchor, :text, :tooltip
21
21
 
data/lib/uniword/image.rb CHANGED
@@ -15,12 +15,6 @@ module Uniword
15
15
  class Image < Element
16
16
  extend LazyLoader
17
17
 
18
- # OOXML namespace configuration for images (drawing elements)
19
- xml do
20
- element "drawing"
21
- namespace Ooxml::Namespaces::WordProcessingML
22
- end
23
-
24
18
  # Image relationship ID (reference to the image file in the package)
25
19
  attribute :relationship_id, :string
26
20
 
@@ -52,6 +46,12 @@ module Uniword
52
46
  # Text wrapping style (:square, :tight, :through, :none, etc.)
53
47
  attribute :text_wrapping, :string
54
48
 
49
+ # OOXML namespace configuration for images (drawing elements)
50
+ xml do
51
+ element "drawing"
52
+ namespace Ooxml::Namespaces::WordProcessingML
53
+ end
54
+
55
55
  # Image data loader (proc that loads the actual binary data)
56
56
  # This is not serialized - it's used for lazy loading
57
57
  attr_accessor :data_loader
@@ -7,8 +7,7 @@ module Uniword
7
7
  # which helps reduce memory usage for large documents.
8
8
  #
9
9
  # All cached values are stored in a single Hash (`@_lazy_cache`)
10
- # accessed via direct ivar reference no instance_variable_get/set,
11
- # no public_send, no remove_instance_variable.
10
+ # accessed via direct ivar reference inside the generated methods.
12
11
  #
13
12
  # @example Using lazy attributes
14
13
  # class Document
@@ -7,21 +7,37 @@ module Uniword
7
7
  # getters/setters generated by lutaml-model for declared attributes.
8
8
  #
9
9
  # Validates that the attribute name exists in the model's schema before
10
- # dispatching unlike raw send() which bypasses visibility checks, this
11
- # only accesses attributes that are part of the model's declared public API.
10
+ # dispatching through the model's public method table only attributes
11
+ # that are part of the model's declared public API can be accessed.
12
12
  module ModelAttributeAccess
13
13
  # Read a declared attribute's value by name.
14
14
  # @param name [Symbol] Declared attribute name
15
15
  # @return [Object] The attribute value
16
+ # @raise [ArgumentError] if the attribute is not declared on the model
16
17
  def read_attribute(name)
17
- __send__(name)
18
+ validate_declared_attribute!(name)
19
+ method(name).call
18
20
  end
19
21
 
20
22
  # Write a declared attribute's value by name.
21
23
  # @param name [Symbol] Declared attribute name
22
24
  # @param value [Object] The value to assign
25
+ # @raise [ArgumentError] if the attribute is not declared on the model
23
26
  def write_attribute(name, value)
24
- __send__(:"#{name}=", value)
27
+ validate_declared_attribute!(name)
28
+ method(:"#{name}=").call(value)
29
+ end
30
+
31
+ private
32
+
33
+ # Validate that the attribute is declared in the model's schema.
34
+ # @param name [Symbol, String] Attribute name
35
+ # @raise [ArgumentError] if the attribute is not declared
36
+ def validate_declared_attribute!(name)
37
+ return if self.class.attributes.key?(name.to_sym)
38
+
39
+ raise ArgumentError,
40
+ "undeclared attribute #{name.inspect} for #{self.class}"
25
41
  end
26
42
  end
27
43
  end
@@ -195,7 +195,13 @@ module Uniword
195
195
  #
196
196
  # @param document [Document] The document to save (Generated::Wordprocessingml::DocumentRoot)
197
197
  # @param path [String] Output path
198
- def self.to_file(document, path, profile: nil)
198
+ # @param profile [Object, nil] Unused (DOCX reconciliation profile)
199
+ # @param validate [Boolean, nil] Run the package integrity gate before
200
+ # writing; nil falls back to Uniword.configuration.validate_on_save
201
+ # @return [void]
202
+ # @raise [Uniword::ValidationError] when the gate is enabled and the
203
+ # generated package content is invalid
204
+ def self.to_file(document, path, profile: nil, validate: nil)
199
205
  # Create package
200
206
  package = new
201
207
 
@@ -217,6 +223,9 @@ module Uniword
217
223
  # Add required OOXML infrastructure files
218
224
  add_required_files(zip_content)
219
225
 
226
+ # Refuse invalid output before packaging
227
+ package.enforce_package_integrity(zip_content, validate)
228
+
220
229
  # Package and save
221
230
  packager = Infrastructure::ZipPackager.new
222
231
  packager.package(zip_content, path)
@@ -225,13 +234,44 @@ module Uniword
225
234
  # Save package to file
226
235
  #
227
236
  # @param path [String] Output path
228
- def to_file(path)
237
+ # @param validate [Boolean, nil] Run the package integrity gate before
238
+ # writing; nil falls back to Uniword.configuration.validate_on_save
239
+ # @return [void]
240
+ # @raise [Uniword::ValidationError] when the gate is enabled and the
241
+ # generated package content is invalid
242
+ def to_file(path, validate: nil)
229
243
  zip_content = to_zip_content
230
244
 
245
+ enforce_package_integrity(zip_content, validate)
246
+
231
247
  packager = Infrastructure::ZipPackager.new
232
248
  packager.package(zip_content, path)
233
249
  end
234
250
 
251
+ # Write-time integrity gate: refuse invalid package content.
252
+ #
253
+ # DOTX is an OPC package like DOCX, so the same
254
+ # Docx::PackageIntegrityChecker invariants apply.
255
+ #
256
+ # @api private
257
+ # @param zip_content [Hash] File paths => content
258
+ # @param validate [Boolean, nil] explicit override; nil reads policy
259
+ # @return [void]
260
+ # @raise [Uniword::ValidationError] listing all integrity issues
261
+ def enforce_package_integrity(zip_content, validate)
262
+ validate = Uniword.configuration.validate_on_save if validate.nil?
263
+ return unless validate
264
+
265
+ issues = Docx::PackageIntegrityChecker.new.check(zip_content)
266
+ return if issues.empty?
267
+
268
+ raise Uniword::ValidationError.new(
269
+ self,
270
+ issues.map { |issue| "#{issue.code} (#{issue.part}): #{issue.message}" },
271
+ issues: issues,
272
+ )
273
+ end
274
+
235
275
  # Generate ZIP content hash
236
276
  #
237
277
  # @return [Hash] File paths => content